957 lines
30 KiB
Dart
957 lines
30 KiB
Dart
// 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';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
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';
|
||
import '../domain/igel_image.dart';
|
||
|
||
class _GalleryScrollBehavior extends MaterialScrollBehavior {
|
||
@override
|
||
Set<PointerDeviceKind> get dragDevices => {
|
||
PointerDeviceKind.touch,
|
||
PointerDeviceKind.mouse,
|
||
PointerDeviceKind.stylus,
|
||
PointerDeviceKind.trackpad,
|
||
PointerDeviceKind.unknown,
|
||
};
|
||
}
|
||
|
||
class IgelGalleryScreen extends ConsumerStatefulWidget {
|
||
const IgelGalleryScreen({
|
||
super.key,
|
||
required this.igelId,
|
||
this.initialIndex = 0,
|
||
});
|
||
|
||
final int igelId;
|
||
final int initialIndex;
|
||
|
||
@override
|
||
ConsumerState<IgelGalleryScreen> createState() => _IgelGalleryState();
|
||
}
|
||
|
||
class _IgelGalleryState extends ConsumerState<IgelGalleryScreen> {
|
||
late final IgelImagesRepository repo;
|
||
List<IgelImage> images = [];
|
||
bool busy = true;
|
||
String? err;
|
||
|
||
// nur EIN Controller, niemals ersetzen!
|
||
late final PageController pageC;
|
||
int currentIndex = 0;
|
||
|
||
// Zoom
|
||
final TransformationController _tc = TransformationController();
|
||
bool _isZoomed = false;
|
||
TapDownDetails? _doubleTapDetails;
|
||
|
||
// UI Overlays (Titel/Buttons)
|
||
bool _chromeVisible = true;
|
||
|
||
// Änderungen melden?
|
||
bool _changed = false;
|
||
|
||
// Upload-Overlay
|
||
bool _uploading = false;
|
||
int _uploadDone = 0;
|
||
int _uploadTotal = 0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
repo = ref.read(igelImagesRepoProvider);
|
||
|
||
currentIndex = widget.initialIndex;
|
||
pageC = PageController(initialPage: currentIndex);
|
||
|
||
if (!kIsWeb) {
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||
SystemChrome.setPreferredOrientations(const [
|
||
DeviceOrientation.portraitUp,
|
||
DeviceOrientation.landscapeLeft,
|
||
DeviceOrientation.landscapeRight,
|
||
]);
|
||
}
|
||
|
||
_tc.addListener(_onMatrixChanged);
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tc.removeListener(_onMatrixChanged);
|
||
_tc.dispose();
|
||
pageC.dispose();
|
||
|
||
if (!kIsWeb) {
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
void _onMatrixChanged() {
|
||
final m = _tc.value;
|
||
final zoomedNow = !_matrixIsIdentity(m);
|
||
if (zoomedNow != _isZoomed && mounted) {
|
||
setState(() => _isZoomed = zoomedNow);
|
||
}
|
||
}
|
||
|
||
bool _matrixIsIdentity(Matrix4 m, {double eps = 1e-3}) {
|
||
final s = m.storage;
|
||
bool eq(double a, double b) => (a - b).abs() <= eps;
|
||
return eq(s[0], 1) &&
|
||
eq(s[5], 1) &&
|
||
eq(s[10], 1) &&
|
||
eq(s[15], 1) &&
|
||
eq(s[1], 0) &&
|
||
eq(s[2], 0) &&
|
||
eq(s[3], 0) &&
|
||
eq(s[4], 0) &&
|
||
eq(s[6], 0) &&
|
||
eq(s[7], 0) &&
|
||
eq(s[8], 0) &&
|
||
eq(s[9], 0) &&
|
||
eq(s[11], 0) &&
|
||
eq(s[12], 0) &&
|
||
eq(s[13], 0) &&
|
||
eq(s[14], 0);
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
setState(() {
|
||
busy = true;
|
||
err = null;
|
||
});
|
||
try {
|
||
final list = await repo.list(widget.igelId);
|
||
images = list;
|
||
|
||
// index clampen – Controller NICHT ersetzen!
|
||
if (images.isEmpty) {
|
||
currentIndex = 0;
|
||
} else {
|
||
currentIndex = math.min(currentIndex, images.length - 1);
|
||
}
|
||
|
||
if (mounted && images.isNotEmpty) {
|
||
await _prefetchAround(currentIndex);
|
||
}
|
||
|
||
// Nach Build ggf. zur aktuellen Seite springen
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (pageC.hasClients && images.isNotEmpty) {
|
||
pageC.jumpToPage(currentIndex);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
err = e.toString();
|
||
} finally {
|
||
if (mounted) setState(() => busy = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _prefetchAround(int index) async {
|
||
if (!mounted || images.isEmpty) return;
|
||
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]);
|
||
if (index - 1 >= 0) await pre(images[index - 1]);
|
||
if (index + 1 < images.length) await pre(images[index + 1]);
|
||
}
|
||
|
||
void _onPageChanged(int i) async {
|
||
setState(() {
|
||
currentIndex = i;
|
||
_tc.value = Matrix4.identity();
|
||
_isZoomed = false;
|
||
});
|
||
await _prefetchAround(i);
|
||
}
|
||
|
||
void _onDoubleTap() {
|
||
if (!_isZoomed && _tc.value == Matrix4.identity()) {
|
||
final pos = _doubleTapDetails?.localPosition ?? const Offset(0, 0);
|
||
const scale = 2.0;
|
||
final z = Matrix4.identity()
|
||
..translate(-pos.dx * (scale - 1), -pos.dy * (scale - 1))
|
||
..scale(scale);
|
||
_tc.value = z;
|
||
} else {
|
||
_tc.value = Matrix4.identity();
|
||
}
|
||
}
|
||
|
||
void _next() {
|
||
if (currentIndex < images.length - 1 && pageC.hasClients) {
|
||
pageC.nextPage(
|
||
duration: const Duration(milliseconds: 160), curve: Curves.easeOut);
|
||
}
|
||
}
|
||
|
||
void _prev() {
|
||
if (currentIndex > 0 && pageC.hasClients) {
|
||
pageC.previousPage(
|
||
duration: const Duration(milliseconds: 160), curve: Curves.easeOut);
|
||
}
|
||
}
|
||
|
||
Future<void> _deleteCurrent() async {
|
||
if (images.isEmpty) return;
|
||
final img = images[currentIndex];
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (_) => AlertDialog(
|
||
title: const Text('Medium löschen?'),
|
||
content: const Text('Dieses Medium wirklich löschen?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('Abbrechen')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: const Text('Löschen')),
|
||
],
|
||
),
|
||
) ??
|
||
false;
|
||
if (!ok) return;
|
||
|
||
try {
|
||
await repo.delete(img.id);
|
||
|
||
// lokal entfernen & NACH dem Rebuild springen
|
||
final oldIndex = currentIndex;
|
||
final wasLast = images.length == 1;
|
||
|
||
setState(() {
|
||
images.removeAt(oldIndex);
|
||
_tc.value = Matrix4.identity();
|
||
_isZoomed = false;
|
||
});
|
||
|
||
_changed = true;
|
||
|
||
if (wasLast) {
|
||
if (mounted) context.pop(true);
|
||
return;
|
||
}
|
||
|
||
final nextIndex = math.max(0, math.min(oldIndex, images.length - 1));
|
||
currentIndex = nextIndex;
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||
if (pageC.hasClients) {
|
||
pageC.jumpToPage(nextIndex);
|
||
}
|
||
await _prefetchAround(nextIndex);
|
||
});
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(const SnackBar(content: Text('Medium gelöscht')));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
|
||
}
|
||
}
|
||
|
||
Future<void> _downloadCurrent() async {
|
||
if (images.isEmpty) return;
|
||
final url = images[currentIndex].url;
|
||
if (kIsWeb) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Im Web per Rechtsklick/Neuer Tab speichern.')),
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
final res = await http.get(Uri.parse(url));
|
||
if (!mounted) return;
|
||
if (res.statusCode == 200) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
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')));
|
||
}
|
||
}
|
||
|
||
// --- Upload in der Galerie -------------------------------------------------
|
||
|
||
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}';
|
||
}
|
||
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;
|
||
}
|
||
|
||
String _fmtDateTime(DateTime dt) {
|
||
final d = dt.toLocal();
|
||
return DateFormat('dd.MM.yyyy, HH:mm').format(d);
|
||
}
|
||
|
||
Future<void> _pickAndUpload() async {
|
||
try {
|
||
final result = await _prepareUploadPayload();
|
||
final files = result.files;
|
||
final takenAt = result.takenAt;
|
||
if (files.isEmpty) return;
|
||
|
||
setState(() {
|
||
_uploading = true;
|
||
_uploadDone = 0;
|
||
_uploadTotal = files.length;
|
||
});
|
||
|
||
await repo.upload(widget.igelId, files, takenAt: takenAt);
|
||
|
||
// Nach Upload neu laden
|
||
final list = await repo.list(widget.igelId);
|
||
images = list;
|
||
_changed = true;
|
||
|
||
currentIndex = images.isNotEmpty ? images.length - 1 : 0;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (pageC.hasClients && images.isNotEmpty) {
|
||
pageC.jumpToPage(currentIndex);
|
||
}
|
||
});
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_uploadDone = _uploadTotal;
|
||
_uploading = false;
|
||
});
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(const SnackBar(content: Text('Medien hochgeladen')));
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() => _uploading = false);
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(SnackBar(content: Text('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> 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);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final total = images.length;
|
||
final canSwipe = !_isZoomed;
|
||
|
||
// Datum/Uhrzeit für Footer (takenAt > createdAt)
|
||
String footerText() {
|
||
if (total == 0) return '';
|
||
final img = images[currentIndex];
|
||
final dt = img.takenAt ?? img.createdAt;
|
||
if (dt == null) return '';
|
||
return _fmtDateTime(dt);
|
||
}
|
||
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, _) => _popWithResult(),
|
||
child: Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: Shortcuts(
|
||
shortcuts: const <ShortcutActivator, Intent>{
|
||
SingleActivator(LogicalKeyboardKey.arrowRight): ActivateIntent(),
|
||
SingleActivator(LogicalKeyboardKey.arrowLeft): DismissIntent(),
|
||
},
|
||
child: Actions(
|
||
actions: <Type, Action<Intent>>{
|
||
ActivateIntent: CallbackAction<Intent>(onInvoke: (_) {
|
||
_next();
|
||
return null;
|
||
}),
|
||
DismissIntent: CallbackAction<Intent>(onInvoke: (_) {
|
||
_prev();
|
||
return null;
|
||
}),
|
||
},
|
||
child: Focus(
|
||
autofocus: true,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _toggleChrome,
|
||
child: SafeArea(
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (busy)
|
||
const Center(child: CircularProgressIndicator())
|
||
else if (err != null)
|
||
Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Text(err!,
|
||
style: const TextStyle(color: Colors.white)),
|
||
),
|
||
)
|
||
else if (total == 0)
|
||
const Center(
|
||
child: Text('Keine Medien',
|
||
style: TextStyle(color: Colors.white70)))
|
||
else
|
||
ScrollConfiguration(
|
||
behavior: _GalleryScrollBehavior(),
|
||
child: PageView.builder(
|
||
controller: pageC,
|
||
onPageChanged: _onPageChanged,
|
||
physics: canSwipe
|
||
? const PageScrollPhysics()
|
||
: const NeverScrollableScrollPhysics(),
|
||
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,
|
||
onDoubleTap: _onDoubleTap,
|
||
child: InteractiveViewer(
|
||
transformationController: _tc,
|
||
minScale: 1,
|
||
maxScale: 4,
|
||
panEnabled: _isZoomed,
|
||
scaleEnabled: true,
|
||
clipBehavior: Clip.none,
|
||
child: Image.network(
|
||
img.url,
|
||
fit: BoxFit.contain,
|
||
errorBuilder: (_, __, ___) => const Icon(
|
||
Icons.broken_image,
|
||
color: Colors.white70,
|
||
size: 64),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// Buttons Prev/Nächste
|
||
if (_chromeVisible && total > 0 && !_isZoomed) ...[
|
||
if (currentIndex > 0)
|
||
Positioned(
|
||
left: 8,
|
||
child: IconButton.filledTonal(
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.white24),
|
||
icon: const Icon(Icons.chevron_left,
|
||
size: 32, color: Colors.white),
|
||
onPressed: _prev,
|
||
),
|
||
),
|
||
if (currentIndex < total - 1)
|
||
Positioned(
|
||
right: 8,
|
||
child: IconButton.filledTonal(
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.white24),
|
||
icon: const Icon(Icons.chevron_right,
|
||
size: 32, color: Colors.white),
|
||
onPressed: _next,
|
||
),
|
||
),
|
||
],
|
||
|
||
// Top-Bar mit Upload-Button
|
||
AnimatedPositioned(
|
||
duration: const Duration(milliseconds: 180),
|
||
top: _chromeVisible ? 0 : -80,
|
||
left: 0,
|
||
right: 0,
|
||
child: Container(
|
||
color: const Color(0x66000000),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6, vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
color: Colors.white,
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: _popWithResult,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
total == 0
|
||
? 'Galerie'
|
||
: '${currentIndex + 1} / $total',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
// Upload-Button (gleich wie im Detail-Screen, aber hier mit Overlay)
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: Colors.white,
|
||
),
|
||
onPressed: _uploading ? null : _pickAndUpload,
|
||
icon: const Icon(Icons.add_a_photo),
|
||
label: const Text('Medien hinzufügen'),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Löschen',
|
||
color: Colors.white,
|
||
onPressed: total == 0 ? null : _deleteCurrent,
|
||
icon: const Icon(Icons.delete_outline),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// Bottom-Bar: Datum/Uhrzeit statt Dateiname
|
||
AnimatedPositioned(
|
||
duration: const Duration(milliseconds: 180),
|
||
bottom: _chromeVisible ? 0 : -72,
|
||
left: 0,
|
||
right: 0,
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Container(
|
||
color: const Color(0x66000000),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
footerText(),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style:
|
||
const TextStyle(color: Colors.white70),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton(
|
||
tooltip: 'Download',
|
||
color: Colors.white,
|
||
onPressed:
|
||
total == 0 ? null : _downloadCurrent,
|
||
icon: const Icon(Icons.download),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Upload-Overlay (Animation)
|
||
if (_uploading)
|
||
Container(
|
||
color: const Color(0x66000000),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(
|
||
width: 56,
|
||
height: 56,
|
||
child:
|
||
CircularProgressIndicator(strokeWidth: 4),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
_uploadTotal <= 1
|
||
? 'Lade Medium hoch …'
|
||
: 'Lade Medien hoch ($_uploadDone/$_uploadTotal) …',
|
||
style: const TextStyle(
|
||
color: Colors.white, fontSize: 16),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|