Video Upload
This commit is contained in:
@@ -33,6 +33,7 @@ class _SplashState extends ConsumerState<SplashScreen>
|
||||
if (!mounted) return;
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 300)); // kleines Fade-Finish
|
||||
if (!mounted) return;
|
||||
context.go(authed ? '/igel' : '/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -36,18 +36,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
FilledButton(
|
||||
onPressed: busy
|
||||
? null
|
||||
: () async {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.login(emailC.text, passC.text);
|
||||
if (mounted) context.go('/igel');
|
||||
} catch (e) {
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
},
|
||||
child: const Text('Einloggen')),
|
||||
: () async {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.login(emailC.text, passC.text);
|
||||
if (!mounted) return;
|
||||
context.go('/igel');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
},
|
||||
child: const Text('Einloggen')),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/register'),
|
||||
child: const Text('Registrieren')),
|
||||
|
||||
@@ -63,8 +63,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await auth.register(email, pass);
|
||||
if (mounted) context.go('/login');
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => err = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
@@ -82,4 +84,4 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,12 @@ class IgelCsvImportService {
|
||||
// Alternativ: "schlanke" CSV nur mit Messwert-Spalten ohne igel_* → dann importieren wir *alle* Zeilen in diesen Igel.
|
||||
final hasAnyIgelCol = (hIgelId >= 0) || (hIgelName >= 0);
|
||||
|
||||
final res = _ImportResult(rows: 0);
|
||||
final res = _ImportResult(
|
||||
rows: 0,
|
||||
igelsCreated: 0,
|
||||
igelsMatched: 0,
|
||||
messwerteCreated: 0,
|
||||
);
|
||||
for (var r = 1; r < rows.length; r++) {
|
||||
final row = rows[r];
|
||||
|
||||
@@ -258,7 +263,12 @@ class IgelCsvImportService {
|
||||
};
|
||||
|
||||
final createdIgels = <String, Igel>{};
|
||||
var res = _ImportResult(rows: rows.length - 1);
|
||||
var res = _ImportResult(
|
||||
rows: rows.length - 1,
|
||||
igelsCreated: 0,
|
||||
igelsMatched: 0,
|
||||
messwerteCreated: 0,
|
||||
);
|
||||
|
||||
// jede Datenzeile
|
||||
for (var r = 1; r < rows.length; r++) {
|
||||
@@ -422,10 +432,10 @@ class _ImportResult {
|
||||
final List<String> errors;
|
||||
|
||||
_ImportResult({
|
||||
this.rows = 0,
|
||||
this.igelsCreated = 0,
|
||||
this.igelsMatched = 0,
|
||||
this.messwerteCreated = 0,
|
||||
required this.rows,
|
||||
required this.igelsCreated,
|
||||
required this.igelsMatched,
|
||||
required this.messwerteCreated,
|
||||
List<String>? errors,
|
||||
}) : errors = errors ?? [];
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/igel/$igelId/shares'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('listShares failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
final List data = jsonDecode(r.body) as List;
|
||||
return data.map((e) => Share.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
@@ -33,8 +34,9 @@ class ShareService {
|
||||
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'email': email, 'role': role}),
|
||||
);
|
||||
if (r.statusCode != 201)
|
||||
if (r.statusCode != 201) {
|
||||
throw Exception('invite failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateRole(
|
||||
@@ -46,8 +48,9 @@ class ShareService {
|
||||
headers: _auth(token, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'role': role}),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('updateRole failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> revoke({required int shareId, required String token}) async {
|
||||
@@ -55,8 +58,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/shares/$shareId'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('revoke failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> acceptInvite(
|
||||
@@ -66,8 +70,9 @@ class ShareService {
|
||||
headers: _auth(tokenJwt, extra: {'Content-Type': 'application/json'}),
|
||||
body: jsonEncode({'token': inviteToken}),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('acceptInvite failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SharedIgelItem>> listSharedWithMe({required String token}) async {
|
||||
@@ -75,8 +80,9 @@ class ShareService {
|
||||
Uri.parse('$baseUrl/hedgehogs.php?r=/me/shared'),
|
||||
headers: _auth(token),
|
||||
);
|
||||
if (r.statusCode != 200)
|
||||
if (r.statusCode != 200) {
|
||||
throw Exception('listSharedWithMe failed (${r.statusCode}) ${r.body}');
|
||||
}
|
||||
final List data = jsonDecode(r.body) as List;
|
||||
return data
|
||||
.map((e) => SharedIgelItem.fromJson(e as Map<String, dynamic>))
|
||||
|
||||
@@ -83,4 +83,15 @@ class IgelImage {
|
||||
takenAt: takenAt ?? this.takenAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isVideo {
|
||||
final m = mime?.toLowerCase();
|
||||
return m != null && m.startsWith('video/');
|
||||
}
|
||||
|
||||
bool get isImage {
|
||||
final m = mime?.toLowerCase();
|
||||
// Wenn MIME fehlt, lieber wie bisher als Bild behandeln.
|
||||
return m == null || m.startsWith('image/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// lib/features/igel/presentation/igel_detail_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../data/share.dart';
|
||||
@@ -219,41 +219,15 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Future<void> _pickAndUpload() async {
|
||||
if (!canEdit) return;
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picks = await picker.pickMultiImage(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return;
|
||||
final files = await _prepareUploadFiles();
|
||||
if (files.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_uploading = true;
|
||||
_uploadDone = 0;
|
||||
_uploadTotal = picks.length;
|
||||
_uploadTotal = files.length;
|
||||
});
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
|
||||
for (final x in picks) {
|
||||
if (kIsWeb) {
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: x.name,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
} else {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
File(x.path).path,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
}
|
||||
setState(() => _uploadDone++);
|
||||
}
|
||||
|
||||
await imagesRepo.upload(widget.igelId, files);
|
||||
|
||||
images = await imagesRepo.list(widget.igelId);
|
||||
@@ -263,20 +237,142 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
_uploading = false;
|
||||
});
|
||||
}
|
||||
_snack('Bilder hochgeladen');
|
||||
_snack('Medien hochgeladen');
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
_snack('Upload fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _mediaPickerUnsupported(Object error) {
|
||||
if (error is UnimplementedError) return true;
|
||||
if (error is MissingPluginException) return true;
|
||||
if (error is PlatformException && error.code == 'unimplemented') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<http.MultipartFile>> _prepareUploadFiles() async {
|
||||
if (!kIsWeb) {
|
||||
final picker = ImagePicker();
|
||||
try {
|
||||
final picks = await picker.pickMultipleMedia(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return [];
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
for (final x in picks) {
|
||||
final mime =
|
||||
_mimeFromName(x.name, hintedMime: x.mimeType, extension: null);
|
||||
final filename = _filenameForUpload(
|
||||
x.name,
|
||||
mime,
|
||||
fallbackExt: mime?.subtype,
|
||||
);
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
}
|
||||
return files;
|
||||
} catch (e) {
|
||||
if (!_mediaPickerUnsupported(e)) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return _pickWithFilePicker();
|
||||
}
|
||||
|
||||
Future<List<http.MultipartFile>> _pickWithFilePicker() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowedExtensions: _mediaExtensions,
|
||||
type: FileType.custom,
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return [];
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
for (final file in result.files) {
|
||||
final bytes = file.bytes;
|
||||
final path = !kIsWeb ? file.path : null;
|
||||
final mime = _mimeFromName(
|
||||
file.name,
|
||||
extension: file.extension,
|
||||
hintedMime: null,
|
||||
);
|
||||
final filename = _filenameForUpload(
|
||||
file.name,
|
||||
mime,
|
||||
fallbackExt: file.extension ?? mime?.subtype,
|
||||
);
|
||||
if (bytes != null) {
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else if (path != null) {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
path,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
Widget _mediaPreview(IgelImage img) {
|
||||
Widget fallback(IconData icon) => ColoredBox(
|
||||
color: const Color(0x11000000),
|
||||
child: Center(child: Icon(icon)),
|
||||
);
|
||||
if (img.isVideo) {
|
||||
final thumb = img.thumbUrl;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (thumb != null)
|
||||
Image.network(
|
||||
thumb,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback(Icons.videocam_off),
|
||||
)
|
||||
else
|
||||
fallback(Icons.videocam_outlined),
|
||||
Container(color: const Color(0x33000000)),
|
||||
const Center(
|
||||
child: Icon(Icons.play_circle_fill,
|
||||
color: Colors.white70, size: 32),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final url = img.thumbUrl ?? img.url;
|
||||
return Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback(Icons.broken_image),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteImage(IgelImage img) async {
|
||||
if (!canEdit) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Bild löschen?'),
|
||||
content: const Text('Dieses Bild wirklich löschen?'),
|
||||
title: const Text('Medium löschen?'),
|
||||
content: const Text('Dieses Medium wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -293,20 +389,116 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
await imagesRepo.delete(img.id);
|
||||
images = await imagesRepo.list(widget.igelId);
|
||||
if (mounted) setState(() {});
|
||||
_snack('Bild gelöscht');
|
||||
_snack('Medium gelöscht');
|
||||
} catch (e) {
|
||||
_snack('Löschen fehlgeschlagen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) {
|
||||
return MediaType('image', 'jpeg');
|
||||
static const List<String> _mediaExtensions = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'gif',
|
||||
'heic',
|
||||
'heif',
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
];
|
||||
|
||||
String _filenameForUpload(String? original, MediaType? mime,
|
||||
{String? fallbackExt}) {
|
||||
var sanitized = (original ?? '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
sanitized = 'upload_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.webp')) return MediaType('image', 'webp');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
final hasExtension = sanitized.contains('.') &&
|
||||
!sanitized.endsWith('.') &&
|
||||
sanitized.split('.').last.trim().isNotEmpty;
|
||||
if (hasExtension) return sanitized;
|
||||
final ext = (fallbackExt?.trim().toLowerCase().replaceAll('.', '')) ??
|
||||
_extensionForMime(mime);
|
||||
if (ext != null) return '$sanitized.$ext';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
String? _extensionForMime(MediaType? mime) {
|
||||
if (mime == null) return null;
|
||||
final type = mime.type.toLowerCase();
|
||||
final sub = mime.subtype.toLowerCase();
|
||||
if (type == 'image') {
|
||||
switch (sub) {
|
||||
case 'jpeg':
|
||||
return 'jpg';
|
||||
case 'png':
|
||||
return 'png';
|
||||
case 'webp':
|
||||
return 'webp';
|
||||
case 'gif':
|
||||
return 'gif';
|
||||
case 'heic':
|
||||
return 'heic';
|
||||
case 'heif':
|
||||
return 'heif';
|
||||
}
|
||||
} else if (type == 'video') {
|
||||
switch (sub) {
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'quicktime':
|
||||
return 'mov';
|
||||
case 'x-msvideo':
|
||||
return 'avi';
|
||||
case 'x-matroska':
|
||||
return 'mkv';
|
||||
case 'webm':
|
||||
return 'webm';
|
||||
case '3gpp':
|
||||
return '3gp';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaType? _tryParseMime(String? mime) {
|
||||
if (mime == null) return null;
|
||||
try {
|
||||
return MediaType.parse(mime);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name,
|
||||
{String? extension, String? hintedMime}) {
|
||||
final hinted = _tryParseMime(hintedMime);
|
||||
if (hinted != null) return hinted;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
final ext = (extension ?? '').toLowerCase();
|
||||
bool hasExt(String e) =>
|
||||
lower.endsWith('.$e') ||
|
||||
(e.startsWith('.') && lower.endsWith(e)) ||
|
||||
ext == e.replaceFirst('.', '');
|
||||
|
||||
if (hasExt('jpg') || hasExt('jpeg')) return MediaType('image', 'jpeg');
|
||||
if (hasExt('png')) return MediaType('image', 'png');
|
||||
if (hasExt('webp')) return MediaType('image', 'webp');
|
||||
if (hasExt('gif')) return MediaType('image', 'gif');
|
||||
if (hasExt('heic')) return MediaType('image', 'heic');
|
||||
if (hasExt('heif')) return MediaType('image', 'heif');
|
||||
if (hasExt('mp4') || hasExt('m4v')) return MediaType('video', 'mp4');
|
||||
if (hasExt('mov')) return MediaType('video', 'quicktime');
|
||||
if (hasExt('avi')) return MediaType('video', 'x-msvideo');
|
||||
if (hasExt('mkv')) return MediaType('video', 'x-matroska');
|
||||
if (hasExt('webm')) return MediaType('video', 'webm');
|
||||
if (hasExt('3gp') || hasExt('3gpp')) return MediaType('video', '3gpp');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -345,10 +537,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d == null) return;
|
||||
if (!mounted || d == null) return;
|
||||
final t = await showTimePicker(
|
||||
context: context, initialTime: TimeOfDay.fromDateTime(mwDatum));
|
||||
if (t == null) return;
|
||||
if (!mounted || t == null) return;
|
||||
setState(() {
|
||||
mwDatum = DateTime(d.year, d.month, d.day, t.hour, t.minute);
|
||||
});
|
||||
@@ -535,6 +727,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Future<List<Uint8List?>> _fetchImagesForPdf() async {
|
||||
final List<Uint8List?> result = [];
|
||||
for (final img in images) {
|
||||
if (!img.isImage) continue;
|
||||
final url = img.thumbUrl ?? img.url;
|
||||
try {
|
||||
final resp =
|
||||
@@ -575,7 +768,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
value: includeBilder,
|
||||
onChanged: (v) =>
|
||||
setStateDialog(() => includeBilder = v ?? false),
|
||||
title: const Text('Bilder aufnehmen'),
|
||||
title: const Text('Medien aufnehmen (Videos werden ausgelassen)'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
@@ -608,10 +801,10 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
includeBilder ? await _fetchImagesForPdf() : const <Uint8List?>[];
|
||||
|
||||
final doc = pw.Document();
|
||||
final textStyle = pw.TextStyle(fontSize: 12);
|
||||
const textStyle = pw.TextStyle(fontSize: 12);
|
||||
final headerStyle =
|
||||
pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold);
|
||||
final labelStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
|
||||
const labelStyle = pw.TextStyle(fontSize: 12, color: PdfColors.grey600);
|
||||
|
||||
pw.Widget infoRow(String label, String value) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
@@ -684,12 +877,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
],
|
||||
if (includeBilder) ...[
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('Bilder',
|
||||
pw.Text('Medien (Fotos)',
|
||||
style:
|
||||
pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 6),
|
||||
if (imageBytesList.isEmpty)
|
||||
pw.Text('Keine Bilder vorhanden.', style: labelStyle)
|
||||
pw.Text('Keine Medien vorhanden.', style: labelStyle)
|
||||
else
|
||||
pw.Wrap(
|
||||
spacing: 8,
|
||||
@@ -718,7 +911,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
borderRadius: pw.BorderRadius.circular(6),
|
||||
),
|
||||
child:
|
||||
pw.Text('Bild nicht verfügbar', style: labelStyle),
|
||||
pw.Text('Medium nicht verfügbar', style: labelStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -741,7 +934,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
_snack('PDF exportiert');
|
||||
}
|
||||
|
||||
String _fmtGramm(int g) => '$g g';
|
||||
String? _emptyToNull(String s) => s.trim().isEmpty ? null : s.trim();
|
||||
|
||||
Future<void> _openEditBasicsDialog() async {
|
||||
@@ -1250,7 +1442,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ----- Bilder-Grid
|
||||
// ----- Medien-Grid
|
||||
if (images.isEmpty)
|
||||
Card(
|
||||
child: SizedBox(
|
||||
@@ -1263,7 +1455,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
Icons.photo_library_outlined,
|
||||
size: 48),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Noch keine Bilder'),
|
||||
const Text('Noch keine Medien'),
|
||||
if (canEdit) ...[
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
@@ -1271,7 +1463,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
icon:
|
||||
const Icon(Icons.add_a_photo),
|
||||
label: const Text(
|
||||
'Bilder hochladen'),
|
||||
'Medien hochladen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1291,7 +1483,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Bilder',
|
||||
Text('Medien',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium),
|
||||
@@ -1301,7 +1493,7 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
icon: const Icon(
|
||||
Icons.add_a_photo),
|
||||
label: const Text(
|
||||
'Bilder hochladen'),
|
||||
'Medien hochladen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1320,8 +1512,6 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
itemCount: images.length,
|
||||
itemBuilder: (_, i) {
|
||||
final img = images[i];
|
||||
final thumb =
|
||||
img.thumbUrl ?? img.url;
|
||||
final String? ts = (() {
|
||||
final dt =
|
||||
img.takenAt ?? img.createdAt;
|
||||
@@ -1355,19 +1545,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
borderRadius:
|
||||
BorderRadius
|
||||
.circular(10),
|
||||
child: Image.network(
|
||||
thumb,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __,
|
||||
___) =>
|
||||
const ColoredBox(
|
||||
color: Color(
|
||||
0x11000000),
|
||||
child: Center(
|
||||
child: Icon(Icons
|
||||
.broken_image)),
|
||||
),
|
||||
),
|
||||
child: _mediaPreview(
|
||||
img),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1409,8 +1588,8 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_uploadTotal <= 1
|
||||
? 'Lade Bild hoch …'
|
||||
: 'Lade Bilder hoch (${_uploadDone}/${_uploadTotal}) …',
|
||||
? 'Lade Medium hoch …'
|
||||
: 'Lade Medien hoch ($_uploadDone/$_uploadTotal) .',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
@@ -1468,10 +1647,10 @@ class _EditMesswertDialogState extends State<_EditMesswertDialog> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d == null) return;
|
||||
if (!mounted || d == null) return;
|
||||
final t = await showTimePicker(
|
||||
context: context, initialTime: TimeOfDay.fromDateTime(datum));
|
||||
if (t == null) return;
|
||||
if (!mounted || t == null) return;
|
||||
setState(() {
|
||||
datum = DateTime(d.year, d.month, d.day, t.hour, t.minute);
|
||||
});
|
||||
@@ -1752,3 +1931,7 @@ class _WeightChartPainter extends CustomPainter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// lib/features/igel/presentation/igel_gallery_screen.dart
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show PointerDeviceKind;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:http_parser/http_parser.dart' show MediaType;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
@@ -164,15 +166,17 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
Future<void> _prefetchAround(int index) async {
|
||||
if (!mounted || images.isEmpty) return;
|
||||
Future<void> pre(String url) async {
|
||||
Future<void> pre(IgelImage img) async {
|
||||
final url = img.isImage ? img.url : img.thumbUrl;
|
||||
if (url == null) return;
|
||||
try {
|
||||
await precacheImage(NetworkImage(url), context);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await pre(images[index].url);
|
||||
if (index - 1 >= 0) await pre(images[index - 1].url);
|
||||
if (index + 1 < images.length) await pre(images[index + 1].url);
|
||||
await pre(images[index]);
|
||||
if (index - 1 >= 0) await pre(images[index - 1]);
|
||||
if (index + 1 < images.length) await pre(images[index + 1]);
|
||||
}
|
||||
|
||||
void _onPageChanged(int i) async {
|
||||
@@ -217,8 +221,8 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Bild löschen?'),
|
||||
content: const Text('Dieses Bild wirklich löschen?'),
|
||||
title: const Text('Medium löschen?'),
|
||||
content: const Text('Dieses Medium wirklich löschen?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
@@ -262,15 +266,13 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
await _prefetchAround(nextIndex);
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Bild gelöscht')));
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Medium gelöscht')));
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,14 +288,16 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
}
|
||||
try {
|
||||
final res = await http.get(Uri.parse(url));
|
||||
if (!mounted) return;
|
||||
if (res.statusCode == 200) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Bild heruntergeladen (im Speicher).')),
|
||||
const SnackBar(content: Text('Medium gespeichert (interner Speicher).')),
|
||||
);
|
||||
} else {
|
||||
throw Exception('HTTP ${res.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Download fehlgeschlagen: $e')));
|
||||
}
|
||||
@@ -301,14 +305,110 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
// --- Upload in der Galerie -------------------------------------------------
|
||||
|
||||
MediaType? _mimeFromName(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) {
|
||||
return MediaType('image', 'jpeg');
|
||||
static const List<String> _mediaExtensions = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'gif',
|
||||
'heic',
|
||||
'heif',
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'avi',
|
||||
'mkv',
|
||||
'webm',
|
||||
'3gp',
|
||||
];
|
||||
|
||||
String _filenameForUpload(String? original, MediaType? mime,
|
||||
{String? fallbackExt}) {
|
||||
var sanitized = (original ?? '').trim();
|
||||
if (sanitized.isEmpty) {
|
||||
sanitized = 'upload_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.webp')) return MediaType('image', 'webp');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
final hasExtension = sanitized.contains('.') &&
|
||||
!sanitized.endsWith('.') &&
|
||||
sanitized.split('.').last.trim().isNotEmpty;
|
||||
if (hasExtension) return sanitized;
|
||||
final ext = (fallbackExt?.trim().toLowerCase().replaceAll('.', '')) ??
|
||||
_extensionForMime(mime);
|
||||
if (ext != null) return '$sanitized.$ext';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
MediaType? _tryParseMime(String? mime) {
|
||||
if (mime == null) return null;
|
||||
try {
|
||||
return MediaType.parse(mime);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _extensionForMime(MediaType? mime) {
|
||||
if (mime == null) return null;
|
||||
final type = mime.type.toLowerCase();
|
||||
final sub = mime.subtype.toLowerCase();
|
||||
if (type == 'image') {
|
||||
switch (sub) {
|
||||
case 'jpeg':
|
||||
return 'jpg';
|
||||
case 'png':
|
||||
return 'png';
|
||||
case 'webp':
|
||||
return 'webp';
|
||||
case 'gif':
|
||||
return 'gif';
|
||||
case 'heic':
|
||||
return 'heic';
|
||||
case 'heif':
|
||||
return 'heif';
|
||||
}
|
||||
} else if (type == 'video') {
|
||||
switch (sub) {
|
||||
case 'mp4':
|
||||
return 'mp4';
|
||||
case 'quicktime':
|
||||
return 'mov';
|
||||
case 'x-msvideo':
|
||||
return 'avi';
|
||||
case 'x-matroska':
|
||||
return 'mkv';
|
||||
case 'webm':
|
||||
return 'webm';
|
||||
case '3gpp':
|
||||
return '3gp';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaType? _mimeFromName(String name,
|
||||
{String? extension, String? hintedMime}) {
|
||||
final hinted = _tryParseMime(hintedMime);
|
||||
if (hinted != null) return hinted;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
final ext = (extension ?? '').toLowerCase();
|
||||
bool hasExt(String e) =>
|
||||
lower.endsWith('.$e') ||
|
||||
(e.startsWith('.') && lower.endsWith(e)) ||
|
||||
ext == e.replaceFirst('.', '');
|
||||
|
||||
if (hasExt('jpg') || hasExt('jpeg')) return MediaType('image', 'jpeg');
|
||||
if (hasExt('png')) return MediaType('image', 'png');
|
||||
if (hasExt('webp')) return MediaType('image', 'webp');
|
||||
if (hasExt('gif')) return MediaType('image', 'gif');
|
||||
if (hasExt('heic')) return MediaType('image', 'heic');
|
||||
if (hasExt('heif')) return MediaType('image', 'heif');
|
||||
if (hasExt('mp4') || hasExt('m4v')) return MediaType('video', 'mp4');
|
||||
if (hasExt('mov')) return MediaType('video', 'quicktime');
|
||||
if (hasExt('avi')) return MediaType('video', 'x-msvideo');
|
||||
if (hasExt('mkv')) return MediaType('video', 'x-matroska');
|
||||
if (hasExt('webm')) return MediaType('video', 'webm');
|
||||
if (hasExt('3gp') || hasExt('3gpp')) return MediaType('video', '3gpp');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,42 +419,17 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picks = await picker.pickMultiImage(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return;
|
||||
final result = await _prepareUploadPayload();
|
||||
final files = result.files;
|
||||
final takenAt = result.takenAt;
|
||||
if (files.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_uploading = true;
|
||||
_uploadDone = 0;
|
||||
_uploadTotal = picks.length;
|
||||
_uploadTotal = files.length;
|
||||
});
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
// Erwartet: List<DateTime?>? → wir füllen (noch) mit nulls
|
||||
final takenAt = <DateTime?>[];
|
||||
|
||||
for (final x in picks) {
|
||||
if (kIsWeb) {
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: x.name,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
} else {
|
||||
files.add(await http.MultipartFile.fromPath('files[]', x.path));
|
||||
}
|
||||
|
||||
// Falls du später EXIF ausliest, hier DateTime setzen.
|
||||
takenAt.add(null);
|
||||
}
|
||||
|
||||
// Upload (Parameter-Typ passt jetzt: List<DateTime?>)
|
||||
await repo.upload(widget.igelId, files, takenAt: takenAt);
|
||||
|
||||
// Nach Upload neu laden
|
||||
@@ -375,7 +450,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
_uploading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Bilder hochgeladen')));
|
||||
.showSnackBar(const SnackBar(content: Text('Medien hochgeladen')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -386,6 +461,107 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _mediaPickerUnsupported(Object error) {
|
||||
if (error is UnimplementedError) return true;
|
||||
if (error is MissingPluginException) return true;
|
||||
if (error is PlatformException && error.code == 'unimplemented') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<({List<http.MultipartFile> files, List<DateTime?> takenAt})>
|
||||
_prepareUploadPayload() async {
|
||||
if (!kIsWeb) {
|
||||
final picker = ImagePicker();
|
||||
try {
|
||||
final picks = await picker.pickMultipleMedia(
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) {
|
||||
return (
|
||||
files: <http.MultipartFile>[],
|
||||
takenAt: <DateTime?>[],
|
||||
);
|
||||
}
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
final takenAt = <DateTime?>[];
|
||||
for (final x in picks) {
|
||||
final mime =
|
||||
_mimeFromName(x.name, hintedMime: x.mimeType, extension: null);
|
||||
final filename =
|
||||
_filenameForUpload(x.name, mime, fallbackExt: mime?.subtype);
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
takenAt.add(null);
|
||||
}
|
||||
return (files: files, takenAt: takenAt);
|
||||
} catch (e) {
|
||||
if (!_mediaPickerUnsupported(e)) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return _pickWithFilePicker();
|
||||
}
|
||||
|
||||
Future<({List<http.MultipartFile> files, List<DateTime?> takenAt})>
|
||||
_pickWithFilePicker() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowedExtensions: _mediaExtensions,
|
||||
type: FileType.custom,
|
||||
withData: kIsWeb,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return (
|
||||
files: <http.MultipartFile>[],
|
||||
takenAt: <DateTime?>[],
|
||||
);
|
||||
}
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
final takenAt = <DateTime?>[];
|
||||
for (final file in result.files) {
|
||||
final bytes = file.bytes;
|
||||
final path = !kIsWeb ? file.path : null;
|
||||
final mime = _mimeFromName(
|
||||
file.name,
|
||||
extension: file.extension,
|
||||
hintedMime: null,
|
||||
);
|
||||
final filename =
|
||||
_filenameForUpload(file.name, mime, fallbackExt: file.extension);
|
||||
if (bytes != null) {
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else if (path != null) {
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
path,
|
||||
filename: filename,
|
||||
contentType: mime,
|
||||
));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
takenAt.add(null);
|
||||
}
|
||||
return (files: files, takenAt: takenAt);
|
||||
}
|
||||
|
||||
|
||||
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
||||
void _popWithResult() => context.pop(_changed);
|
||||
|
||||
@@ -403,11 +579,9 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
return _fmtDateTime(dt);
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
_popWithResult();
|
||||
return false;
|
||||
},
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) => _popWithResult(),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Shortcuts(
|
||||
@@ -447,7 +621,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
)
|
||||
else if (total == 0)
|
||||
const Center(
|
||||
child: Text('Keine Bilder',
|
||||
child: Text('Keine Medien',
|
||||
style: TextStyle(color: Colors.white70)))
|
||||
else
|
||||
ScrollConfiguration(
|
||||
@@ -461,6 +635,15 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
itemCount: total,
|
||||
itemBuilder: (_, i) {
|
||||
final img = images[i];
|
||||
if (img.isVideo) {
|
||||
return Center(
|
||||
child: _GalleryVideoPlayer(
|
||||
key: ValueKey('video-${img.id}'),
|
||||
media: img,
|
||||
isActive: currentIndex == i,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTapDown: (d) => _doubleTapDetails = d,
|
||||
@@ -549,7 +732,7 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
),
|
||||
onPressed: _uploading ? null : _pickAndUpload,
|
||||
icon: const Icon(Icons.add_a_photo),
|
||||
label: const Text('Bilder hinzufügen'),
|
||||
label: const Text('Medien hinzufügen'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Löschen',
|
||||
@@ -616,8 +799,8 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_uploadTotal <= 1
|
||||
? 'Lade Bild hoch …'
|
||||
: 'Lade Bilder hoch ($_uploadDone/$_uploadTotal) …',
|
||||
? 'Lade Medium hoch …'
|
||||
: 'Lade Medien hoch ($_uploadDone/$_uploadTotal) …',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 16),
|
||||
),
|
||||
@@ -636,3 +819,138 @@ class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryVideoPlayer extends StatefulWidget {
|
||||
const _GalleryVideoPlayer({
|
||||
super.key,
|
||||
required this.media,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
final IgelImage media;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
State<_GalleryVideoPlayer> createState() => _GalleryVideoPlayerState();
|
||||
}
|
||||
|
||||
class _GalleryVideoPlayerState extends State<_GalleryVideoPlayer> {
|
||||
VideoPlayerController? _controller;
|
||||
Future<void>? _init;
|
||||
bool _hasError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _GalleryVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.media.url != widget.media.url) {
|
||||
_disposeController();
|
||||
_initController();
|
||||
} else if (oldWidget.isActive != widget.isActive) {
|
||||
_updatePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
void _initController() {
|
||||
_hasError = false;
|
||||
final ctrl =
|
||||
VideoPlayerController.networkUrl(Uri.parse(widget.media.url));
|
||||
_controller = ctrl;
|
||||
_init = ctrl.initialize().then((_) {
|
||||
ctrl.setLooping(true);
|
||||
_updatePlayback();
|
||||
if (mounted) setState(() {});
|
||||
}).catchError((_) {
|
||||
if (mounted) setState(() => _hasError = true);
|
||||
});
|
||||
}
|
||||
|
||||
void _updatePlayback() {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null || !ctrl.value.isInitialized) return;
|
||||
if (widget.isActive) {
|
||||
ctrl.play();
|
||||
} else {
|
||||
ctrl.pause();
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePlay() {
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null || !ctrl.value.isInitialized) return;
|
||||
if (ctrl.value.isPlaying) {
|
||||
ctrl.pause();
|
||||
} else {
|
||||
ctrl.play();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeController() {
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
_init = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_hasError) {
|
||||
return const Icon(Icons.videocam_off, color: Colors.white70, size: 72);
|
||||
}
|
||||
final ctrl = _controller;
|
||||
if (ctrl == null) {
|
||||
return const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
return FutureBuilder(
|
||||
future: _init,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done ||
|
||||
!ctrl.value.isInitialized) {
|
||||
return const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
final aspect =
|
||||
ctrl.value.aspectRatio == 0 ? (16 / 9) : ctrl.value.aspectRatio;
|
||||
final isPlaying = ctrl.value.isPlaying;
|
||||
return GestureDetector(
|
||||
onTap: _togglePlay,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: aspect,
|
||||
child: VideoPlayer(ctrl),
|
||||
),
|
||||
if (!isPlaying)
|
||||
const Icon(Icons.play_circle_fill,
|
||||
color: Colors.white70, size: 72),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,7 +315,8 @@ class _IgelListState extends ConsumerState<IgelListScreen> {
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async {
|
||||
await ref.read(tokenStorageProvider).clear();
|
||||
if (context.mounted) context.go('/login');
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user