Video Upload
This commit is contained in:
@@ -31,7 +31,11 @@ define('UPLOAD_THUMB_DIR', __DIR__ . '/hedgehogs/uploads/thumbs');
|
||||
// Öffentliche Basis-URL zu genau diesen Ordnern
|
||||
define('UPLOAD_BASE_URL', 'https://api.windesign.at/hedgehogs/uploads');
|
||||
|
||||
define('MAX_IMAGE_SIZE', 8 * 1024 * 1024);
|
||||
define('MAX_IMAGE_SIZE', 8 * 1024 * 1024); // Legacy Limit f�r Bilder
|
||||
// Neues, gemeinsames Limit f�r Bilder/Videos (an php.ini upload_max_filesize anpassen!)
|
||||
if (!defined('MAX_MEDIA_SIZE')) {
|
||||
define('MAX_MEDIA_SIZE', 250 * 1024 * 1024); // 250 MB
|
||||
}
|
||||
if (!defined('ALLOWED_MIME')) {
|
||||
define('ALLOWED_MIME', json_encode(['image/jpeg','image/png','image/webp','image/gif']));
|
||||
}
|
||||
|
||||
+163
-30
@@ -11,8 +11,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('display_errors', '0'); // API: keine HTML-Fehler ausgeben (bricht JSON)
|
||||
ini_set('display_startup_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// --- Load settings -----------------------------------------------------------
|
||||
@@ -506,6 +507,56 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
// Hilfsfunktion: Extension/MIME erkennen (inkl. Videos)
|
||||
$detectMedia = function (string $origName, string $fileType): array {
|
||||
$lower = strtolower($origName);
|
||||
$ext = '.bin';
|
||||
$mime = 'application/octet-stream';
|
||||
$isImage = false;
|
||||
|
||||
$extFromLower = function (array $map) use ($lower): ?array {
|
||||
foreach ($map as $pattern => $info) {
|
||||
if (preg_match($pattern, $lower)) {
|
||||
return $info;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// bevorzugt Endung, fallback auf gelieferten Content-Type
|
||||
$info = $extFromLower([
|
||||
'/\.(jpg|jpeg)$/' => ['.jpg', 'image/jpeg', true],
|
||||
'/\.png$/' => ['.png', 'image/png', true],
|
||||
'/\.webp$/' => ['.webp', 'image/webp', true],
|
||||
'/\.gif$/' => ['.gif', 'image/gif', true],
|
||||
'/\.heic$/' => ['.heic', 'image/heic', true],
|
||||
'/\.heif$/' => ['.heif', 'image/heif', true],
|
||||
'/\.(mp4|m4v)$/' => ['.mp4', 'video/mp4', false],
|
||||
'/\.mov$/' => ['.mov', 'video/quicktime', false],
|
||||
'/\.avi$/' => ['.avi', 'video/x-msvideo', false],
|
||||
'/\.mkv$/' => ['.mkv', 'video/x-matroska', false],
|
||||
'/\.webm$/' => ['.webm', 'video/webm', false],
|
||||
'/\.(3gp|3gpp)$/' => ['.3gp', 'video/3gpp', false],
|
||||
]);
|
||||
|
||||
if ($info) {
|
||||
[$ext, $mime, $isImage] = $info;
|
||||
} elseif ($fileType) {
|
||||
$t = strtolower($fileType);
|
||||
if (str_starts_with($t, 'image/')) {
|
||||
$mime = $t;
|
||||
$ext = '.jpg';
|
||||
$isImage = true;
|
||||
} elseif (str_starts_with($t, 'video/')) {
|
||||
$mime = $t;
|
||||
$ext = '.mp4';
|
||||
$isImage = false;
|
||||
}
|
||||
}
|
||||
|
||||
return [$ext, $mime, $isImage];
|
||||
};
|
||||
$count = is_array($files['name']) ? count($files['name']) : 0;
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if ((int) $files['error'][$i] !== UPLOAD_ERR_OK)
|
||||
@@ -514,26 +565,14 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void
|
||||
$orig = (string) $files['name'][$i];
|
||||
$size = (int) $files['size'][$i];
|
||||
|
||||
if ($size <= 0 || $size > MAX_IMAGE_SIZE)
|
||||
$maxSize = defined('MAX_MEDIA_SIZE') ? MAX_MEDIA_SIZE : MAX_IMAGE_SIZE;
|
||||
|
||||
if ($size <= 0 || $size > $maxSize)
|
||||
continue;
|
||||
|
||||
// MIME grob anhand Endung
|
||||
$lower = strtolower($orig);
|
||||
$ext = '.bin';
|
||||
$mime = 'application/octet-stream';
|
||||
if (preg_match('/\.(jpg|jpeg)$/', $lower)) {
|
||||
$ext = '.jpg';
|
||||
$mime = 'image/jpeg';
|
||||
} elseif (preg_match('/\.png$/', $lower)) {
|
||||
$ext = '.png';
|
||||
$mime = 'image/png';
|
||||
} elseif (preg_match('/\.webp$/', $lower)) {
|
||||
$ext = '.webp';
|
||||
$mime = 'image/webp';
|
||||
} elseif (preg_match('/\.gif$/', $lower)) {
|
||||
$ext = '.gif';
|
||||
$mime = 'image/gif';
|
||||
}
|
||||
// MIME/Extension erkennen
|
||||
$fileType = is_array($files['type']) && isset($files['type'][$i]) ? (string) $files['type'][$i] : '';
|
||||
[$ext, $mime, $isImage] = $detectMedia($orig, $fileType);
|
||||
|
||||
// sichere Dateinamen
|
||||
$base = bin2hex(random_bytes(8));
|
||||
@@ -547,18 +586,37 @@ function igel_images_upload(PDO $pdo, int $uid, int $igId): void
|
||||
if (!move_uploaded_file($tmp, $dest))
|
||||
continue;
|
||||
|
||||
// Thumb
|
||||
// Thumb (für Bilder echtes Thumbnail, für Videos Frame/Placeholder)
|
||||
$thumbUrl = null;
|
||||
try {
|
||||
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
|
||||
if (!is_dir($thumbDir)) {
|
||||
@mkdir($thumbDir, 0755, true);
|
||||
$thumbBase = $base . ($isImage ? $ext : '.png');
|
||||
if ($isImage) {
|
||||
try {
|
||||
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
|
||||
if (!is_dir($thumbDir)) {
|
||||
@mkdir($thumbDir, 0755, true);
|
||||
}
|
||||
$thumbPath = $thumbDir . '/' . $thumbBase;
|
||||
create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box
|
||||
$thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $thumbBase;
|
||||
} catch (Throwable $e) {
|
||||
$thumbUrl = null; // ok
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
|
||||
if (!is_dir($thumbDir)) {
|
||||
@mkdir($thumbDir, 0755, true);
|
||||
}
|
||||
$thumbPath = $thumbDir . '/' . $thumbBase;
|
||||
try {
|
||||
create_video_thumbnail($dest, $thumbPath, 512, 512);
|
||||
} catch (Throwable $e) {
|
||||
create_video_placeholder($thumbPath, 512, 288);
|
||||
}
|
||||
$thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $thumbBase;
|
||||
} catch (Throwable $e) {
|
||||
$thumbUrl = null;
|
||||
}
|
||||
$thumbPath = $thumbDir . '/' . $fn;
|
||||
create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box
|
||||
$thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $fn;
|
||||
} catch (Throwable $e) {
|
||||
$thumbUrl = null; // ok
|
||||
}
|
||||
|
||||
$url = rtrim(UPLOAD_BASE_URL, '/') . '/' . $fn;
|
||||
@@ -701,6 +759,81 @@ function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void
|
||||
imagedestroy($thumb);
|
||||
}
|
||||
|
||||
// Placeholder für Video-Thumbnails (ohne ffmpeg)
|
||||
function create_video_placeholder(string $dest, int $w = 512, int $h = 288): void
|
||||
{
|
||||
if (!extension_loaded('gd'))
|
||||
throw new Exception('GD not loaded');
|
||||
|
||||
$im = imagecreatetruecolor($w, $h);
|
||||
$bg = imagecolorallocate($im, 34, 34, 34);
|
||||
imagefilledrectangle($im, 0, 0, $w, $h, $bg);
|
||||
|
||||
$accent = imagecolorallocate($im, 240, 240, 240);
|
||||
$txt = imagecolorallocate($im, 180, 180, 180);
|
||||
|
||||
// Play-Icon (Dreieck)
|
||||
$size = (int) min($w, $h) * 0.3;
|
||||
$cx = (int) ($w / 2);
|
||||
$cy = (int) ($h / 2);
|
||||
$half = (int) ($size / 2);
|
||||
$points = [
|
||||
$cx - (int) ($half * 0.7), $cy - $half,
|
||||
$cx - (int) ($half * 0.7), $cy + $half,
|
||||
$cx + $half, $cy,
|
||||
];
|
||||
imagefilledpolygon($im, $points, 3, $accent);
|
||||
|
||||
// Rand
|
||||
$border = imagecolorallocatealpha($im, 255, 255, 255, 60);
|
||||
imagerectangle($im, 0, 0, $w - 1, $h - 1, $border);
|
||||
|
||||
// Text "VIDEO"
|
||||
$label = 'VIDEO';
|
||||
$fontSize = 5; // built-in font
|
||||
$tw = imagefontwidth($fontSize) * strlen($label);
|
||||
$th = imagefontheight($fontSize);
|
||||
imagestring($im, $fontSize, (int) (($w - $tw) / 2), $h - $th - 6, $label, $txt);
|
||||
|
||||
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
|
||||
if ($ext === 'jpg' || $ext === 'jpeg') {
|
||||
imagejpeg($im, $dest, 85);
|
||||
} else {
|
||||
imagepng($im, $dest, 6);
|
||||
}
|
||||
|
||||
imagedestroy($im);
|
||||
}
|
||||
|
||||
// Video-Thumbnail �ber ffmpeg (erstes Frame)
|
||||
function create_video_thumbnail(string $src, string $dest, int $maxW, int $maxH): void
|
||||
{
|
||||
$ffmpeg = trim((string) @shell_exec('command -v ffmpeg'));
|
||||
if ($ffmpeg === '') {
|
||||
throw new Exception('ffmpeg not available');
|
||||
}
|
||||
|
||||
$srcEsc = escapeshellarg($src);
|
||||
$destEsc = escapeshellarg($dest);
|
||||
// Skaliert proportional, dann schwarze R�nder auf Quadrat
|
||||
$scale = sprintf(
|
||||
'scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(%d-iw)/2:(%d-ih)/2',
|
||||
$maxW,
|
||||
$maxH,
|
||||
$maxW,
|
||||
$maxH,
|
||||
$maxW,
|
||||
$maxH
|
||||
);
|
||||
$cmd = "$ffmpeg -y -v error -i $srcEsc -frames:v 1 -vf \"$scale\" $destEsc";
|
||||
$out = [];
|
||||
$ret = 0;
|
||||
@exec($cmd, $out, $ret);
|
||||
if ($ret !== 0 || !is_file($dest)) {
|
||||
throw new Exception('ffmpeg failed');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MESSWERTE
|
||||
// =============================================================================
|
||||
|
||||
@@ -33,6 +33,7 @@ class _SplashState extends ConsumerState<SplashScreen>
|
||||
if (!mounted) return;
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 300)); // kleines Fade-Finish
|
||||
if (!mounted) return;
|
||||
context.go(authed ? '/igel' : '/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -36,18 +36,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
FilledButton(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () async {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.login(emailC.text, passC.text);
|
||||
if (mounted) context.go('/igel');
|
||||
} catch (e) {
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
},
|
||||
child: const Text('Einloggen')),
|
||||
: () async {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.login(emailC.text, passC.text);
|
||||
if (!mounted) return;
|
||||
context.go('/igel');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
},
|
||||
child: const Text('Einloggen')),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/register'),
|
||||
child: const Text('Registrieren')),
|
||||
|
||||
@@ -63,8 +63,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.register(email, pass);
|
||||
if (mounted) context.go('/login');
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
@@ -82,4 +84,4 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,12 @@ class IgelCsvImportService {
|
||||
// Alternativ: "schlanke" CSV nur mit Messwert-Spalten ohne igel_* → dann importieren wir *alle* Zeilen in diesen Igel.
|
||||
final hasAnyIgelCol = (hIgelId >= 0) || (hIgelName >= 0);
|
||||
|
||||
final res = _ImportResult(rows: 0);
|
||||
final res = _ImportResult(
|
||||
rows: 0,
|
||||
igelsCreated: 0,
|
||||
igelsMatched: 0,
|
||||
messwerteCreated: 0,
|
||||
);
|
||||
for (var r = 1; r < rows.length; r++) {
|
||||
final row = rows[r];
|
||||
|
||||
@@ -258,7 +263,12 @@ class IgelCsvImportService {
|
||||
};
|
||||
|
||||
final createdIgels = <String, Igel>{};
|
||||
var res = _ImportResult(rows: rows.length - 1);
|
||||
var res = _ImportResult(
|
||||
rows: rows.length - 1,
|
||||
igelsCreated: 0,
|
||||
igelsMatched: 0,
|
||||
messwerteCreated: 0,
|
||||
);
|
||||
|
||||
// jede Datenzeile
|
||||
for (var r = 1; r < rows.length; r++) {
|
||||
@@ -422,10 +432,10 @@ class _ImportResult {
|
||||
final List<String> errors;
|
||||
|
||||
_ImportResult({
|
||||
this.rows = 0,
|
||||
this.igelsCreated = 0,
|
||||
this.igelsMatched = 0,
|
||||
this.messwerteCreated = 0,
|
||||
required this.rows,
|
||||
required this.igelsCreated,
|
||||
required this.igelsMatched,
|
||||
required this.messwerteCreated,
|
||||
List<String>? errors,
|
||||
}) : errors = errors ?? [];
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('listShares failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
final List data = jsonDecode(r.body) as List;
|
||||
return data.map((e) => Share.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
@@ -33,8 +34,9 @@ class ShareService {
|
||||
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'email': email, 'role': role}),
|
||||
);
|
||||
if (r.statusCode != 201)
|
||||
if (r.statusCode != 201) {
|
||||
throw Exception('invite failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateRole(
|
||||
@@ -46,8 +48,9 @@ class ShareService {
|
||||
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'role': role}),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('updateRole failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> revoke({required int shareId, required String token}) async {
|
||||
@@ -55,8 +58,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('revoke failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> acceptInvite(
|
||||
@@ -66,8 +70,9 @@ class ShareService {
|
||||
headers: _auth(tokenJwt, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'token': inviteToken}),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('acceptInvite failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SharedIgelItem>> listSharedWithMe({required String token}) async {
|
||||
@@ -75,8 +80,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/me/shared'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('listSharedWithMe failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
final List data = jsonDecode(r.body) as List;
|
||||
return data
|
||||
.map((e) => SharedIgelItem.fromJson(e as Map<String, dynamic>))
|
||||
|
||||
@@ -83,4 +83,15 @@ class IgelImage {
|
||||
takenAt: takenAt ?? this.takenAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isVideo {
|
||||
final m = mime?.toLowerCase();
|
||||
return m != null && m.startsWith('video/');
|
||||
}
|
||||
|
||||
bool get isImage {
|
||||
final m = mime?.toLowerCase();
|
||||
// Wenn MIME fehlt, lieber wie bisher als Bild behandeln.
|
||||
return m == null || m.startsWith('image/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// lib/features/igel/presentation/igel_detail_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../data/share.dart';
|
||||
@@ -219,41 +219,15 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Future<void> _pickAndUpload() async {
|
||||
if (!canEdit) return;
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picks = await picker.pickMultiImage(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return;
|
||||
final files = await _prepareUploadFiles();
|
||||
if (files.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_uploading = true;
|
||||
_uploadDone = 0;
|
||||
_uploadTotal = picks.length;
|
||||
_uploadTotal = files.length;
|
||||
});
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
|
||||
for (final x in picks) {
|
||||
if (kIsWeb) {
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: x.name,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
} else {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
File(x.path).path,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
}
|
||||
setState(() => _uploadDone++);
|
||||
}
|
||||
|
||||
await imagesRepo.upload(widget.igelId, files);
|
||||
|
||||
images = await imagesRepo.list(widget.igelId);
|
||||
@@ -263,20 +237,142 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
_uploading = false;
|
||||
});
|
||||
}
|
||||
_snack('Bilder hochgeladen');
|
||||
_snack('Medien hochgeladen');
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
_snack('Upload fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _mediaPickerUnsupported(Object error) {
|
||||
if (error is UnimplementedError) return true;
|
||||
if (error is MissingPluginException) return true;
|
||||
if (error is PlatformException && error.code == 'unimplemented') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<http.MultipartFile>> _prepareUploadFiles() async {
|
||||
if (!kIsWeb) {
|
||||
final picker = ImagePicker();
|
||||
try {
|
||||
final picks = await picker.pickMultipleMedia(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return [];
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
for (final x in picks) {
|
||||
final mime =
|
||||
_mimeFromName(x.name, hintedMime: x.mimeType, extension: null);
|
||||
final filename = _filenameForUpload(
|
||||
x.name,
|
||||
mime,
|
||||
fallbackExt: mime?.subtype,
|
||||
);
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
}
|
||||
return files;
|
||||
} catch (e) {
|
||||
if (!_mediaPickerUnsupported(e)) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return _pickWithFilePicker();
|
||||
}
|
||||
|
||||
Future<List<http.MultipartFile>> _pickWithFilePicker() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowedExtensions: _mediaExtensions,
|
||||
type: FileType.custom,
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return [];
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
for (final file in result.files) {
|
||||
final bytes = file.bytes;
|
||||
final path = !kIsWeb ? file.path : null;
|
||||
final mime = _mimeFromName(
|
||||
file.name,
|
||||
extension: file.extension,
|
||||
hintedMime: null,
|
||||
);
|
||||
final filename = _filenameForUpload(
|
||||
file.name,
|
||||
mime,
|
||||
fallbackExt: file.extension ?? mime?.subtype,
|
||||
);
|
||||
if (bytes != null) {
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else if (path != null) {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
path,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
Widget _mediaPreview(IgelImage img) {
|
||||
Widget fallback(IconData icon) => ColoredBox(
|
||||
color: const Color(0x11000000),
|
||||
child: Center(child: Icon(icon)),
|
||||
);
|
||||
if (img.isVideo) {
|
||||
final thumb = img.thumbUrl;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (thumb != null)
|
||||
Image.network(
|
||||
thumb,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback(Icons.videocam_off),
|
||||
)
|
||||
else
|
||||
fallback(Icons.videocam_outlined),
|
||||
Container(color: const Color(0x33000000)),
|
||||
const Center(
|
||||
child: Icon(Icons.play_circle_fill,
|
||||
color: Colors.white70, size: 32),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final url = img.thumbUrl ?? img.url;
|
||||
return Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback(Icons.broken_image),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteImage(IgelImage img) async {
|
||||
if (!canEdit) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Bild löschen?'),
|
||||
content: const Text('Dieses Bild wirklich löschen?'),
|
||||
title: const Text('Medium löschen?'),
|
||||
content: const Text('Dieses Medium wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -293,20 +389,116 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
await imagesRepo.delete(img.id);
|
||||
images = await imagesRepo.list(widget.igelId);
|
||||
if (mounted) setState(() {});
|
||||
_snack('Bild gelöscht');
|
||||
_snack('Medium gelöscht');
|
||||
} catch (e) {
|
||||
_snack('Löschen fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) {
|
||||
return MediaType('image', 'jpeg');
|
||||
static const List<String> _mediaExtensions = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'gif',
|
||||
'heic',
|
||||
'heif',
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
];
|
||||
|
||||
String _filenameForUpload(String? original, MediaType? mime,
|
||||
{String? fallbackExt}) {
|
||||
var sanitized = (original ?? '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
sanitized = 'upload_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.webp')) return MediaType('image', 'webp');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
final hasExtension = sanitized.contains('.') &&
|
||||
!sanitized.endsWith('.') &&
|
||||
sanitized.split('.').last.trim().isNotEmpty;
|
||||
if (hasExtension) return sanitized;
|
||||
final ext = (fallbackExt?.trim().toLowerCase().replaceAll('.', '')) ??
|
||||
_extensionForMime(mime);
|
||||
if (ext != null) return '$sanitized.$ext';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
String? _extensionForMime(MediaType? mime) {
|
||||
if (mime == null) return null;
|
||||
final type = mime.type.toLowerCase();
|
||||
final sub = mime.subtype.toLowerCase();
|
||||
if (type == 'image') {
|
||||
switch (sub) {
|
||||
case 'jpeg':
|
||||
return 'jpg';
|
||||
case 'png':
|
||||
return 'png';
|
||||
case 'webp':
|
||||
return 'webp';
|
||||
case 'gif':
|
||||
return 'gif';
|
||||
case 'heic':
|
||||
return 'heic';
|
||||
case 'heif':
|
||||
return 'heif';
|
||||
}
|
||||
} else if (type == 'video') {
|
||||
switch (sub) {
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'quicktime':
|
||||
return 'mov';
|
||||
case 'x-msvideo':
|
||||
return 'avi';
|
||||
case 'x-matroska':
|
||||
return 'mkv';
|
||||
case 'webm':
|
||||
return 'webm';
|
||||
case '3gpp':
|
||||
return '3gp';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaType? _tryParseMime(String? mime) {
|
||||
if (mime == null) return null;
|
||||
try {
|
||||
return MediaType.parse(mime);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name,
|
||||
{String? extension, String? hintedMime}) {
|
||||
final hinted = _tryParseMime(hintedMime);
|
||||
if (hinted != null) return hinted;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
final ext = (extension ?? '').toLowerCase();
|
||||
bool hasExt(String e) =>
|
||||
lower.endsWith('.$e') ||
|
||||
(e.startsWith('.') && lower.endsWith(e)) ||
|
||||
ext == e.replaceFirst('.', '');
|
||||
|
||||
if (hasExt('jpg') || hasExt('jpeg')) return MediaType('image', 'jpeg');
|
||||
if (hasExt('png')) return MediaType('image', 'png');
|
||||
if (hasExt('webp')) return MediaType('image', 'webp');
|
||||
if (hasExt('gif')) return MediaType('image', 'gif');
|
||||
if (hasExt('heic')) return MediaType('image', 'heic');
|
||||
if (hasExt('heif')) return MediaType('image', 'heif');
|
||||
if (hasExt('mp4') || hasExt('m4v')) return MediaType('video', 'mp4');
|
||||
if (hasExt('mov')) return MediaType('video', 'quicktime');
|
||||
if (hasExt('avi')) return MediaType('video', 'x-msvideo');
|
||||
if (hasExt('mkv')) return MediaType('video', 'x-matroska');
|
||||
if (hasExt('webm')) return MediaType('video', 'webm');
|
||||
if (hasExt('3gp') || hasExt('3gpp')) return MediaType('video', '3gpp');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -345,10 +537,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d == null) return;
|
||||
if (!mounted || d == null) return;
|
||||
final t = await showTimePicker(
|
||||
context: context, initialTime: TimeOfDay.fromDateTime(mwDatum));
|
||||
if (t == null) return;
|
||||
if (!mounted || t == null) return;
|
||||
setState(() {
|
||||
mwDatum = DateTime(d.year, d.month, d.day, t.hour, t.minute);
|
||||
});
|
||||
@@ -535,6 +727,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Future<List<Uint8List?>> _fetchImagesForPdf() async {
|
||||
final List<Uint8List?> result = [];
|
||||
for (final img in images) {
|
||||
if (!img.isImage) continue;
|
||||
final url = img.thumbUrl ?? img.url;
|
||||
try {
|
||||
final resp =
|
||||
@@ -575,7 +768,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
value: includeBilder,
|
||||
onChanged: (v) =>
|
||||
setStateDialog(() => includeBilder = v ?? false),
|
||||
title: const Text('Bilder aufnehmen'),
|
||||
title: const Text('Medien aufnehmen (Videos werden ausgelassen)'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
@@ -608,10 +801,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
includeBilder ? await _fetchImagesForPdf() : const <Uint8List?>[];
|
||||
|
||||
final doc = pw.Document();
|
||||
final textStyle = pw.TextStyle(fontSize: 12);
|
||||
const textStyle = pw.TextStyle(fontSize: 12);
|
||||
final headerStyle =
|
||||
pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold);
|
||||
final labelStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
|
||||
const labelStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
|
||||
|
||||
pw.Widget infoRow(String label, String value) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
@@ -684,12 +877,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
],
|
||||
if (includeBilder) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Bilder',
|
||||
pw.Text('Medien (Fotos)',
|
||||
style:
|
||||
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 6),
|
||||
if (imageBytesList.isEmpty)
|
||||
pw.Text('Keine Bilder vorhanden.', style: labelStyle)
|
||||
pw.Text('Keine Medien vorhanden.', style: labelStyle)
|
||||
else
|
||||
pw.Wrap(
|
||||
spacing: 8,
|
||||
@@ -718,7 +911,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
borderRadius: pw.BorderRadius.circular(6),
|
||||
),
|
||||
child:
|
||||
pw.Text('Bild nicht verfügbar', style: labelStyle),
|
||||
pw.Text('Medium nicht verfügbar', style: labelStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -741,7 +934,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
_snack('PDF exportiert');
|
||||
}
|
||||
|
||||
String _fmtGramm(int g) => '$g g';
|
||||
String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim();
|
||||
|
||||
Future<void> _openEditBasicsDialog() async {
|
||||
@@ -1250,7 +1442,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ----- Bilder-Grid
|
||||
// ----- Medien-Grid
|
||||
if (images.isEmpty)
|
||||
Card(
|
||||
child: SizedBox(
|
||||
@@ -1263,7 +1455,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Icons.photo_library_outlined,
|
||||
size: 48),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Noch keine Bilder'),
|
||||
const Text('Noch keine Medien'),
|
||||
if (canEdit) ...[
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
@@ -1271,7 +1463,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
icon:
|
||||
const Icon(Icons.add_a_photo),
|
||||
label: const Text(
|
||||
'Bilder hochladen'),
|
||||
'Medien hochladen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1291,7 +1483,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Bilder',
|
||||
Text('Medien',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium),
|
||||
@@ -1301,7 +1493,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
icon: const Icon(
|
||||
Icons.add_a_photo),
|
||||
label: const Text(
|
||||
'Bilder hochladen'),
|
||||
'Medien hochladen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1320,8 +1512,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
itemCount: images.length,
|
||||
itemBuilder: (_, i) {
|
||||
final img = images[i];
|
||||
final thumb =
|
||||
img.thumbUrl ?? img.url;
|
||||
final String? ts = (() {
|
||||
final dt =
|
||||
img.takenAt ?? img.createdAt;
|
||||
@@ -1355,19 +1545,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
borderRadius:
|
||||
BorderRadius
|
||||
.circular(10),
|
||||
child: Image.network(
|
||||
thumb,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __,
|
||||
___) =>
|
||||
const ColoredBox(
|
||||
color: Color(
|
||||
0x11000000),
|
||||
child: Center(
|
||||
child: Icon(Icons
|
||||
.broken_image)),
|
||||
),
|
||||
),
|
||||
child: _mediaPreview(
|
||||
img),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1409,8 +1588,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_uploadTotal <= 1
|
||||
? 'Lade Bild hoch …'
|
||||
: 'Lade Bilder hoch (${_uploadDone}/${_uploadTotal}) …',
|
||||
? 'Lade Medium hoch …'
|
||||
: 'Lade Medien hoch ($_uploadDone/$_uploadTotal) .',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
@@ -1468,10 +1647,10 @@ class _EditMesswertDialogState extends State<_EditMesswertDialog> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d == null) return;
|
||||
if (!mounted || d == null) return;
|
||||
final t = await showTimePicker(
|
||||
context: context, initialTime: TimeOfDay.fromDateTime(datum));
|
||||
if (t == null) return;
|
||||
if (!mounted || t == null) return;
|
||||
setState(() {
|
||||
datum = DateTime(d.year, d.month, d.day, t.hour, t.minute);
|
||||
});
|
||||
@@ -1752,3 +1931,7 @@ class _WeightChartPainter extends CustomPainter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// lib/features/igel/presentation/igel_gallery_screen.dart
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show PointerDeviceKind;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:http_parser/http_parser.dart' show MediaType;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
@@ -164,15 +166,17 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
Future<void> _prefetchAround(int index) async {
|
||||
if (!mounted || images.isEmpty) return;
|
||||
Future<void> pre(String url) async {
|
||||
Future<void> pre(IgelImage img) async {
|
||||
final url = img.isImage ? img.url : img.thumbUrl;
|
||||
if (url == null) return;
|
||||
try {
|
||||
await precacheImage(NetworkImage(url), context);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await pre(images[index].url);
|
||||
if (index - 1 >= 0) await pre(images[index - 1].url);
|
||||
if (index + 1 < images.length) await pre(images[index + 1].url);
|
||||
await pre(images[index]);
|
||||
if (index - 1 >= 0) await pre(images[index - 1]);
|
||||
if (index + 1 < images.length) await pre(images[index + 1]);
|
||||
}
|
||||
|
||||
void _onPageChanged(int i) async {
|
||||
@@ -217,8 +221,8 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Bild löschen?'),
|
||||
content: const Text('Dieses Bild wirklich löschen?'),
|
||||
title: const Text('Medium löschen?'),
|
||||
content: const Text('Dieses Medium wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -262,15 +266,13 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
await _prefetchAround(nextIndex);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Bild gelöscht')));
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Medium gelöscht')));
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,14 +288,16 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
}
|
||||
try {
|
||||
final res = await http.get(Uri.parse(url));
|
||||
if (!mounted) return;
|
||||
if (res.statusCode == 200) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bild heruntergeladen (im Speicher).')),
|
||||
const SnackBar(content: Text('Medium gespeichert (interner Speicher).')),
|
||||
);
|
||||
} else {
|
||||
throw Exception('HTTP ${res.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Download fehlgeschlagen: $e')));
|
||||
}
|
||||
@@ -301,14 +305,110 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
// --- Upload in der Galerie -------------------------------------------------
|
||||
|
||||
MediaType? _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) {
|
||||
return MediaType('image', 'jpeg');
|
||||
static const List<String> _mediaExtensions = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'gif',
|
||||
'heic',
|
||||
'heif',
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
];
|
||||
|
||||
String _filenameForUpload(String? original, MediaType? mime,
|
||||
{String? fallbackExt}) {
|
||||
var sanitized = (original ?? '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
sanitized = 'upload_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.webp')) return MediaType('image', 'webp');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
final hasExtension = sanitized.contains('.') &&
|
||||
!sanitized.endsWith('.') &&
|
||||
sanitized.split('.').last.trim().isNotEmpty;
|
||||
if (hasExtension) return sanitized;
|
||||
final ext = (fallbackExt?.trim().toLowerCase().replaceAll('.', '')) ??
|
||||
_extensionForMime(mime);
|
||||
if (ext != null) return '$sanitized.$ext';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
MediaType? _tryParseMime(String? mime) {
|
||||
if (mime == null) return null;
|
||||
try {
|
||||
return MediaType.parse(mime);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _extensionForMime(MediaType? mime) {
|
||||
if (mime == null) return null;
|
||||
final type = mime.type.toLowerCase();
|
||||
final sub = mime.subtype.toLowerCase();
|
||||
if (type == 'image') {
|
||||
switch (sub) {
|
||||
case 'jpeg':
|
||||
return 'jpg';
|
||||
case 'png':
|
||||
return 'png';
|
||||
case 'webp':
|
||||
return 'webp';
|
||||
case 'gif':
|
||||
return 'gif';
|
||||
case 'heic':
|
||||
return 'heic';
|
||||
case 'heif':
|
||||
return 'heif';
|
||||
}
|
||||
} else if (type == 'video') {
|
||||
switch (sub) {
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'quicktime':
|
||||
return 'mov';
|
||||
case 'x-msvideo':
|
||||
return 'avi';
|
||||
case 'x-matroska':
|
||||
return 'mkv';
|
||||
case 'webm':
|
||||
return 'webm';
|
||||
case '3gpp':
|
||||
return '3gp';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name,
|
||||
{String? extension, String? hintedMime}) {
|
||||
final hinted = _tryParseMime(hintedMime);
|
||||
if (hinted != null) return hinted;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
final ext = (extension ?? '').toLowerCase();
|
||||
bool hasExt(String e) =>
|
||||
lower.endsWith('.$e') ||
|
||||
(e.startsWith('.') && lower.endsWith(e)) ||
|
||||
ext == e.replaceFirst('.', '');
|
||||
|
||||
if (hasExt('jpg') || hasExt('jpeg')) return MediaType('image', 'jpeg');
|
||||
if (hasExt('png')) return MediaType('image', 'png');
|
||||
if (hasExt('webp')) return MediaType('image', 'webp');
|
||||
if (hasExt('gif')) return MediaType('image', 'gif');
|
||||
if (hasExt('heic')) return MediaType('image', 'heic');
|
||||
if (hasExt('heif')) return MediaType('image', 'heif');
|
||||
if (hasExt('mp4') || hasExt('m4v')) return MediaType('video', 'mp4');
|
||||
if (hasExt('mov')) return MediaType('video', 'quicktime');
|
||||
if (hasExt('avi')) return MediaType('video', 'x-msvideo');
|
||||
if (hasExt('mkv')) return MediaType('video', 'x-matroska');
|
||||
if (hasExt('webm')) return MediaType('video', 'webm');
|
||||
if (hasExt('3gp') || hasExt('3gpp')) return MediaType('video', '3gpp');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,42 +419,17 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picks = await picker.pickMultiImage(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return;
|
||||
final result = await _prepareUploadPayload();
|
||||
final files = result.files;
|
||||
final takenAt = result.takenAt;
|
||||
if (files.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_uploading = true;
|
||||
_uploadDone = 0;
|
||||
_uploadTotal = picks.length;
|
||||
_uploadTotal = files.length;
|
||||
});
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
// Erwartet: List<DateTime?>? → wir füllen (noch) mit nulls
|
||||
final takenAt = <DateTime?>[];
|
||||
|
||||
for (final x in picks) {
|
||||
if (kIsWeb) {
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: x.name,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
} else {
|
||||
files.add(await http.MultipartFile.fromPath('files[]', x.path));
|
||||
}
|
||||
|
||||
// Falls du später EXIF ausliest, hier DateTime setzen.
|
||||
takenAt.add(null);
|
||||
}
|
||||
|
||||
// Upload (Parameter-Typ passt jetzt: List<DateTime?>)
|
||||
await repo.upload(widget.igelId, files, takenAt: takenAt);
|
||||
|
||||
// Nach Upload neu laden
|
||||
@@ -375,7 +450,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
_uploading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Bilder hochgeladen')));
|
||||
.showSnackBar(const SnackBar(content: Text('Medien hochgeladen')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -386,6 +461,107 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _mediaPickerUnsupported(Object error) {
|
||||
if (error is UnimplementedError) return true;
|
||||
if (error is MissingPluginException) return true;
|
||||
if (error is PlatformException && error.code == 'unimplemented') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<({List<http.MultipartFile> files, List<DateTime?> takenAt})>
|
||||
_prepareUploadPayload() async {
|
||||
if (!kIsWeb) {
|
||||
final picker = ImagePicker();
|
||||
try {
|
||||
final picks = await picker.pickMultipleMedia(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) {
|
||||
return (
|
||||
files: <http.MultipartFile>[],
|
||||
takenAt: <DateTime?>[],
|
||||
);
|
||||
}
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
final takenAt = <DateTime?>[];
|
||||
for (final x in picks) {
|
||||
final mime =
|
||||
_mimeFromName(x.name, hintedMime: x.mimeType, extension: null);
|
||||
final filename =
|
||||
_filenameForUpload(x.name, mime, fallbackExt: mime?.subtype);
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
takenAt.add(null);
|
||||
}
|
||||
return (files: files, takenAt: takenAt);
|
||||
} catch (e) {
|
||||
if (!_mediaPickerUnsupported(e)) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return _pickWithFilePicker();
|
||||
}
|
||||
|
||||
Future<({List<http.MultipartFile> files, List<DateTime?> takenAt})>
|
||||
_pickWithFilePicker() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowedExtensions: _mediaExtensions,
|
||||
type: FileType.custom,
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return (
|
||||
files: <http.MultipartFile>[],
|
||||
takenAt: <DateTime?>[],
|
||||
);
|
||||
}
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
final takenAt = <DateTime?>[];
|
||||
for (final file in result.files) {
|
||||
final bytes = file.bytes;
|
||||
final path = !kIsWeb ? file.path : null;
|
||||
final mime = _mimeFromName(
|
||||
file.name,
|
||||
extension: file.extension,
|
||||
hintedMime: null,
|
||||
);
|
||||
final filename =
|
||||
_filenameForUpload(file.name, mime, fallbackExt: file.extension);
|
||||
if (bytes != null) {
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else if (path != null) {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
path,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
takenAt.add(null);
|
||||
}
|
||||
return (files: files, takenAt: takenAt);
|
||||
}
|
||||
|
||||
|
||||
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
||||
void _popWithResult() => context.pop(_changed);
|
||||
|
||||
@@ -403,11 +579,9 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
return _fmtDateTime(dt);
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
_popWithResult();
|
||||
return false;
|
||||
},
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) => _popWithResult(),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Shortcuts(
|
||||
@@ -447,7 +621,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
)
|
||||
else if (total == 0)
|
||||
const Center(
|
||||
child: Text('Keine Bilder',
|
||||
child: Text('Keine Medien',
|
||||
style: TextStyle(color: Colors.white70)))
|
||||
else
|
||||
ScrollConfiguration(
|
||||
@@ -461,6 +635,15 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
itemCount: total,
|
||||
itemBuilder: (_, i) {
|
||||
final img = images[i];
|
||||
if (img.isVideo) {
|
||||
return Center(
|
||||
child: _GalleryVideoPlayer(
|
||||
key: ValueKey('video-${img.id}'),
|
||||
media: img,
|
||||
isActive: currentIndex == i,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTapDown: (d) => _doubleTapDetails = d,
|
||||
@@ -549,7 +732,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
),
|
||||
onPressed: _uploading ? null : _pickAndUpload,
|
||||
icon: const Icon(Icons.add_a_photo),
|
||||
label: const Text('Bilder hinzufügen'),
|
||||
label: const Text('Medien hinzufügen'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Löschen',
|
||||
@@ -616,8 +799,8 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_uploadTotal <= 1
|
||||
? 'Lade Bild hoch …'
|
||||
: 'Lade Bilder hoch ($_uploadDone/$_uploadTotal) …',
|
||||
? 'Lade Medium hoch …'
|
||||
: 'Lade Medien hoch ($_uploadDone/$_uploadTotal) …',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 16),
|
||||
),
|
||||
@@ -636,3 +819,138 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryVideoPlayer extends StatefulWidget {
|
||||
const _GalleryVideoPlayer({
|
||||
super.key,
|
||||
required this.media,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
final IgelImage media;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
State<_GalleryVideoPlayer> createState() => _GalleryVideoPlayerState();
|
||||
}
|
||||
|
||||
class _GalleryVideoPlayerState extends State<_GalleryVideoPlayer> {
|
||||
VideoPlayerController? _controller;
|
||||
Future<void>? _init;
|
||||
bool _hasError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _GalleryVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.media.url != widget.media.url) {
|
||||
_disposeController();
|
||||
_initController();
|
||||
} else if (oldWidget.isActive != widget.isActive) {
|
||||
_updatePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
void _initController() {
|
||||
_hasError = false;
|
||||
final ctrl =
|
||||
VideoPlayerController.networkUrl(Uri.parse(widget.media.url));
|
||||
_controller = ctrl;
|
||||
_init = ctrl.initialize().then((_) {
|
||||
ctrl.setLooping(true);
|
||||
_updatePlayback();
|
||||
if (mounted) setState(() {});
|
||||
}).catchError((_) {
|
||||
if (mounted) setState(() => _hasError = true);
|
||||
});
|
||||
}
|
||||
|
||||
void _updatePlayback() {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null || !ctrl.value.isInitialized) return;
|
||||
if (widget.isActive) {
|
||||
ctrl.play();
|
||||
} else {
|
||||
ctrl.pause();
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePlay() {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null || !ctrl.value.isInitialized) return;
|
||||
if (ctrl.value.isPlaying) {
|
||||
ctrl.pause();
|
||||
} else {
|
||||
ctrl.play();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeController() {
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
_init = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_hasError) {
|
||||
return const Icon(Icons.videocam_off, color: Colors.white70, size: 72);
|
||||
}
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null) {
|
||||
return const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
return FutureBuilder(
|
||||
future: _init,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done ||
|
||||
!ctrl.value.isInitialized) {
|
||||
return const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
final aspect =
|
||||
ctrl.value.aspectRatio == 0 ? (16 / 9) : ctrl.value.aspectRatio;
|
||||
final isPlaying = ctrl.value.isPlaying;
|
||||
return GestureDetector(
|
||||
onTap: _togglePlay,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect,
|
||||
child: VideoPlayer(ctrl),
|
||||
),
|
||||
if (!isPlaying)
|
||||
const Icon(Icons.play_circle_fill,
|
||||
color: Colors.white70, size: 72),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,7 +315,8 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async {
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
if (context.mounted) context.go('/login');
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import printing
|
||||
import share_plus
|
||||
import video_player_avfoundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
@@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
}
|
||||
|
||||
@@ -198,6 +198,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
csv:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -424,6 +432,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -973,6 +989,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.5"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.16"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.1"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.5"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ dependencies:
|
||||
printing: ^5.13.4
|
||||
csv: ^5.0.2
|
||||
file_picker: ^8.0.0
|
||||
video_player: ^2.9.1
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.11
|
||||
@@ -28,4 +29,4 @@ dev_dependencies:
|
||||
flutter_lints: ^3.0.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
uses-material-design: true
|
||||
|
||||
Reference in New Issue
Block a user