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
@@ -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');
},
),
],