Files
2025-11-16 22:40:55 +01:00

957 lines
30 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),
],
),
);
},
);
}
}