From 1f981376f66c3da83be6c5bdb9f2a7dbbd0f39a6 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Sun, 16 Nov 2025 22:40:55 +0100 Subject: [PATCH] Video Upload --- api/hedgehogs-settings.php | 6 +- api/hedgehogs.php | 193 ++++++-- .../auth/application/splash_screen.dart | 1 + .../auth/presentation/login_screen.dart | 26 +- .../auth/presentation/register_screen.dart | 6 +- lib/features/igel/data/import_service.dart | 22 +- lib/features/igel/data/share_service.dart | 18 +- lib/features/igel/domain/igel_image.dart | 11 + .../igel/presentation/igel_detail_screen.dart | 333 ++++++++++--- .../presentation/igel_gallery_screen.dart | 442 +++++++++++++++--- .../igel/presentation/igel_list_screen.dart | 3 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 +++ pubspec.yaml | 3 +- 14 files changed, 926 insertions(+), 196 deletions(-) diff --git a/api/hedgehogs-settings.php b/api/hedgehogs-settings.php index 2a57ab6..d47dcd6 100644 --- a/api/hedgehogs-settings.php +++ b/api/hedgehogs-settings.php @@ -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'])); } diff --git a/api/hedgehogs.php b/api/hedgehogs.php index c32a8ff..7428985 100644 --- a/api/hedgehogs.php +++ b/api/hedgehogs.php @@ -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 // ============================================================================= diff --git a/lib/features/auth/application/splash_screen.dart b/lib/features/auth/application/splash_screen.dart index fa3387f..1244dd2 100644 --- a/lib/features/auth/application/splash_screen.dart +++ b/lib/features/auth/application/splash_screen.dart @@ -33,6 +33,7 @@ class _SplashState extends ConsumerState if (!mounted) return; await Future.delayed( const Duration(milliseconds: 300)); // kleines Fade-Finish + if (!mounted) return; context.go(authed ? '/igel' : '/login'); } diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 4bb65e3..b08e850 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -36,18 +36,20 @@ class _LoginScreenState extends ConsumerState { 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')), diff --git a/lib/features/auth/presentation/register_screen.dart b/lib/features/auth/presentation/register_screen.dart index 828ab0f..388d5cc 100644 --- a/lib/features/auth/presentation/register_screen.dart +++ b/lib/features/auth/presentation/register_screen.dart @@ -63,8 +63,10 @@ class _RegisterScreenState extends ConsumerState { 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 { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/igel/data/import_service.dart b/lib/features/igel/data/import_service.dart index f8414fd..b6ba9c9 100644 --- a/lib/features/igel/data/import_service.dart +++ b/lib/features/igel/data/import_service.dart @@ -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 = {}; - 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 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? errors, }) : errors = errors ?? []; } diff --git a/lib/features/igel/data/share_service.dart b/lib/features/igel/data/share_service.dart index de8ca5f..4ff2f6a 100644 --- a/lib/features/igel/data/share_service.dart +++ b/lib/features/igel/data/share_service.dart @@ -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)).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 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 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 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> 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)) diff --git a/lib/features/igel/domain/igel_image.dart b/lib/features/igel/domain/igel_image.dart index 9a6c82d..0541a16 100644 --- a/lib/features/igel/domain/igel_image.dart +++ b/lib/features/igel/domain/igel_image.dart @@ -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/'); + } } diff --git a/lib/features/igel/presentation/igel_detail_screen.dart b/lib/features/igel/presentation/igel_detail_screen.dart index 2b6d085..7cfa956 100644 --- a/lib/features/igel/presentation/igel_detail_screen.dart +++ b/lib/features/igel/presentation/igel_detail_screen.dart @@ -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 { Future _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 = []; - - 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 { _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> _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 = []; + 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> _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 = []; + 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 _deleteImage(IgelImage img) async { if (!canEdit) return; final ok = await showDialog( 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 { 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 _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 { 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 { Future> _fetchImagesForPdf() async { final List 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 { 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 { includeBilder ? await _fetchImagesForPdf() : const []; 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 { ], 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 { 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 { _snack('PDF exportiert'); } - String _fmtGramm(int g) => '$g g'; String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim(); Future _openEditBasicsDialog() async { @@ -1250,7 +1442,7 @@ class _IgelDetailState extends ConsumerState { ), ), const SizedBox(height: 12), - // ----- Bilder-Grid + // ----- Medien-Grid if (images.isEmpty) Card( child: SizedBox( @@ -1263,7 +1455,7 @@ class _IgelDetailState extends ConsumerState { 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 { icon: const Icon(Icons.add_a_photo), label: const Text( - 'Bilder hochladen'), + 'Medien hochladen'), ), ], ], @@ -1291,7 +1483,7 @@ class _IgelDetailState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Bilder', + Text('Medien', style: Theme.of(context) .textTheme .titleMedium), @@ -1301,7 +1493,7 @@ class _IgelDetailState extends ConsumerState { icon: const Icon( Icons.add_a_photo), label: const Text( - 'Bilder hochladen'), + 'Medien hochladen'), ), ], ), @@ -1320,8 +1512,6 @@ class _IgelDetailState extends ConsumerState { 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 { 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 { 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; } } + + + + diff --git a/lib/features/igel/presentation/igel_gallery_screen.dart b/lib/features/igel/presentation/igel_gallery_screen.dart index 1594b14..a5a6222 100644 --- a/lib/features/igel/presentation/igel_gallery_screen.dart +++ b/lib/features/igel/presentation/igel_gallery_screen.dart @@ -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 { Future _prefetchAround(int index) async { if (!mounted || images.isEmpty) return; - Future pre(String url) async { + Future 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 { final ok = await showDialog( 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 { 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 { } 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 { // --- 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 _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 { Future _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 = []; - // Erwartet: List? → wir füllen (noch) mit nulls - final takenAt = []; - - 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) await repo.upload(widget.igelId, files, takenAt: takenAt); // Nach Upload neu laden @@ -375,7 +450,7 @@ class _IgelGalleryState extends ConsumerState { _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 { } } + 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 files, List 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: [], + takenAt: [], + ); + } + + final files = []; + final takenAt = []; + 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 files, List 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: [], + takenAt: [], + ); + } + + final files = []; + final takenAt = []; + 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 { 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 { ) 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 { 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 { ), 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 { 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 { ); } } + +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? _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(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(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), + ], + ), + ); + }, + ); + } +} + diff --git a/lib/features/igel/presentation/igel_list_screen.dart b/lib/features/igel/presentation/igel_list_screen.dart index 4a10bab..d0e29ce 100644 --- a/lib/features/igel/presentation/igel_list_screen.dart +++ b/lib/features/igel/presentation/igel_list_screen.dart @@ -315,7 +315,8 @@ class _IgelListState extends ConsumerState { icon: const Icon(Icons.logout), onPressed: () async { await ref.read(tokenStorageProvider).clear(); - if (context.mounted) context.go('/login'); + if (!mounted) return; + context.go('/login'); }, ), ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 41c7a0a..19f4509 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index bbfe962..5122ced 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index da292a4..82191d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 \ No newline at end of file + uses-material-design: true