Video Upload

This commit is contained in:
2025-11-16 22:40:55 +01:00
parent a3b8900047
commit 1f981376f6
14 changed files with 926 additions and 196 deletions
+5 -1
View File
@@ -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 fr Bilder
// Neues, gemeinsames Limit fr 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
View File
@@ -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 Rnder 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> {
),
);
}
}
}
+16 -6
View File
@@ -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 ?? [];
}
+12 -6
View File
@@ -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>))
+11
View File
@@ -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"))
}
+56
View File
@@ -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
View File
@@ -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