import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import '../../routing/app_routes.dart'; import '../app_progress_indicator.dart'; import '../file_viewer/local_file_actions.dart'; import '../info_dialog.dart'; import 'gallery_cache.dart'; import 'gallery_image_page.dart'; import 'gallery_item.dart'; import 'gallery_source.dart'; import 'gallery_video_page.dart'; enum _GalleryAction { openExternal, sendToChat, saveToCloud, share, save } /// Full-screen, swipeable view over the images and videos of a chat or /// folder. Owns (and disposes) [source]. class MediaGalleryViewer extends StatefulWidget { final GallerySource source; final String initialKey; const MediaGalleryViewer({ required this.source, required this.initialKey, super.key, }); @override State createState() => _MediaGalleryViewerState(); } class _MediaGalleryViewerState extends State { /// Neighbours whose previews are fetched ahead, per side. static const _precacheRange = 2; late final PageController _pageController; final ValueNotifier _rotateSignal = ValueNotifier(0); late String _anchorKey; late int _index; bool _busy = false; List get _items => widget.source.items; GalleryItem get _current => _items[_index]; @override void initState() { super.initState(); _anchorKey = widget.initialKey; _index = _items.indexWhere((item) => item.key == _anchorKey); if (_index < 0) _index = 0; _pageController = PageController(initialPage: _index); widget.source.addListener(_onSourceChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _onIndexSettled(); }); } @override void dispose() { widget.source.removeListener(_onSourceChanged); widget.source.dispose(); _pageController.dispose(); _rotateSignal.dispose(); super.dispose(); } /// Older chat media lands in front of the shown item; jump along so the /// user keeps looking at the same picture. void _onSourceChanged() { if (!mounted) return; final index = _items.indexWhere((item) => item.key == _anchorKey); setState(() { if (index >= 0) _index = index; }); if (index >= 0 && _pageController.hasClients && _pageController.page?.round() != index) { _pageController.jumpToPage(index); } _precacheAround(); } void _onPageChanged(int index) { setState(() { _index = index; _anchorKey = _items[index].key; }); _onIndexSettled(); } void _onIndexSettled() { _precacheAround(); if (_index <= _precacheRange && widget.source.hasOlder) { unawaited(widget.source.loadOlder()); } } void _precacheAround() { for (var d = -_precacheRange; d <= _precacheRange; d++) { final i = _index + d; if (d == 0 || i < 0 || i >= _items.length) continue; unawaited( precacheImage( galleryPreviewProvider(context, _items[i]), context, onError: (_, _) {}, ), ); } } Future _handleAction(_GalleryAction action) async { final item = _current; switch (action) { case _GalleryAction.sendToChat: AppRoutes.openInternalShareToChat(context, item.file); return; case _GalleryAction.saveToCloud: AppRoutes.openInternalSaveToFolder(context, item.file); return; case _GalleryAction.openExternal: case _GalleryAction.share: case _GalleryAction.save: break; } if (_busy) return; setState(() => _busy = true); final String path; try { path = await GalleryOriginals.instance.namedCopy(item); } on Object catch (e) { if (mounted) InfoDialog.showError(context, e); return; } finally { if (mounted) setState(() => _busy = false); } if (!mounted) return; switch (action) { case _GalleryAction.openExternal: AppRoutes.openFileViewer( context, path, openExternal: true, remoteFile: item.file, ); case _GalleryAction.share: await shareLocalFile(context, path); case _GalleryAction.save: await saveLocalFile(context, path); case _GalleryAction.sendToChat: case _GalleryAction.saveToCloud: break; } } PreferredSizeWidget _appBar(BuildContext context) { final item = _current; final subtitle = item.subtitle; final position = '${_index + 1} / ${_items.length}'; return AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(item.file.name, maxLines: 1, overflow: TextOverflow.ellipsis), Text( subtitle == null ? position : '$position · $subtitle', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ if (_busy) const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: Center(child: AppProgressIndicator.small()), ), if (!item.isVideo) IconButton( onPressed: () => _rotateSignal.value++, tooltip: 'Drehen', icon: const Icon(Icons.rotate_right), ), PopupMenuButton<_GalleryAction>( tooltip: 'Dateiaktionen', onSelected: (action) => unawaited(_handleAction(action)), itemBuilder: (context) => [ _menuItem( _GalleryAction.openExternal, Platform.isIOS ? Icons.ios_share : Icons.open_in_new, Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit', ), _menuItem( _GalleryAction.sendToChat, Icons.chat_bubble_outline, 'An Talk-Chat senden', ), _menuItem( _GalleryAction.saveToCloud, Icons.cloud_outlined, 'In Cloud speichern', ), _menuItem(_GalleryAction.share, Icons.share_outlined, 'Teilen'), _menuItem( _GalleryAction.save, Icons.save_alt_outlined, 'Speichern', ), ], ), ], ); } PopupMenuItem<_GalleryAction> _menuItem( _GalleryAction action, IconData icon, String label, ) => PopupMenuItem( value: action, child: ListTile(leading: Icon(icon), title: Text(label), dense: true), ); int? _indexForKey(Key key) { if (key is! ValueKey) return null; final index = _items.indexWhere((item) => item.key == key.value); return index < 0 ? null : index; } @override Widget build(BuildContext context) => Scaffold( appBar: _appBar(context), backgroundColor: Theme.of(context).colorScheme.surface, // The scope lets a page's PhotoView hand horizontal drags to the // PageView once the image is panned to its edge. body: PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( controller: _pageController, itemCount: _items.length, onPageChanged: _onPageChanged, allowImplicitScrolling: true, findChildIndexCallback: _indexForKey, itemBuilder: (context, index) { final item = _items[index]; final isActive = index == _index; return ClipRect( key: ValueKey(item.key), child: item.isVideo ? GalleryVideoPage(item: item, isActive: isActive) : GalleryImagePage( item: item, isActive: isActive, rotateSignal: _rotateSignal, ), ); }, ), ), ); }