diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 7cb5afd..f6adc4c 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -165,7 +165,17 @@ class RichObjectString { String? path; String? link; - RichObjectString(this.type, this.id, this.name, this.path, this.link); + /// Only set for `file` objects. + String? mimetype; + + RichObjectString( + this.type, + this.id, + this.name, + this.path, + this.link, [ + this.mimetype, + ]); factory RichObjectString.fromJson(Map json) => _$RichObjectStringFromJson(json); diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart index b3080ea..69fff38 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart @@ -135,6 +135,7 @@ RichObjectString _$RichObjectStringFromJson(Map json) => $checkedConvert('name', (v) => v as String), $checkedConvert('path', (v) => v as String?), $checkedConvert('link', (v) => v as String?), + $checkedConvert('mimetype', (v) => v as String?), ); return val; }); @@ -146,6 +147,7 @@ Map _$RichObjectStringToJson(RichObjectString instance) => 'name': instance.name, 'path': instance.path, 'link': instance.link, + 'mimetype': instance.mimetype, }; const _$RichObjectStringObjectTypeEnumMap = { diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart index 1bcb9f1..1c0ebdb 100644 --- a/lib/model/session_wipe.dart +++ b/lib/model/session_wipe.dart @@ -18,6 +18,7 @@ import '../share_intent/share_intent_listener.dart'; import '../utils/app_paths.dart'; import '../utils/downloads/download_manager.dart'; import '../utils/file_clipboard.dart'; +import '../widget/media_gallery/gallery_cache.dart'; import '../widget_data/widget_sync.dart'; /// Removes what an account leaves on the device outside its own storage (see @@ -55,6 +56,7 @@ abstract final class SessionWipe { final dir = await DownloadManager.downloadsDirectory(); if (dir.existsSync()) await dir.delete(recursive: true); }); + await _stepAsync('gallery originals', GalleryOriginals.instance.clear); await _stepAsync( 'notification tray', NotificationService().flutterLocalNotificationsPlugin.cancelAll, diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 499d91b..2527ae5 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -47,6 +47,8 @@ import '../widget/debug/cache_view.dart'; import '../widget/debug/json_viewer.dart'; import '../widget/file_viewer.dart'; import '../widget/large_profile_picture_view.dart'; +import '../widget/media_gallery/gallery_source.dart'; +import '../widget/media_gallery/media_gallery_viewer.dart'; import '../widget/user_avatar.dart'; /// Single entry point for full-page navigations. Dialogs and bottom sheets @@ -97,6 +99,19 @@ class AppRoutes { ); } + /// Swipeable full-screen gallery; the viewer takes ownership of [source]. + static void openMediaGallery( + BuildContext context, { + required GallerySource source, + required String initialKey, + }) { + pushScreen( + context, + withNavBar: false, + screen: MediaGalleryViewer(source: source, initialKey: initialKey), + ); + } + static void openCustomEvents(BuildContext context) { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index c0b6cf3..fcb0eb8 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -140,8 +140,12 @@ class _FilesViewState extends State<_FilesView> { return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, - itemBuilder: (context, index) => - FileElement(files[index], widget.path, bloc.refresh), + itemBuilder: (context, index) => FileElement( + files[index], + widget.path, + bloc.refresh, + siblings: files, + ), ); }, ), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 9bb54ef..be4597d 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -17,6 +17,8 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/downloads/download_trigger.dart'; +import '../../../../widget/media_gallery/gallery_item.dart'; +import '../../../../widget/media_gallery/gallery_source.dart'; import '../../../../widget/prompt_dialog.dart'; import '../../talk/widgets/highlighted_linkify.dart'; import '../data/webdav_path.dart'; @@ -33,11 +35,16 @@ class FileElement extends StatefulWidget { /// highlighted in the tile title. Used by the Files search delegate. final String? highlight; + /// The listing [file] belongs to, in display order; the media gallery pages + /// through its images and videos. Null opens the gallery on [file] alone. + final List? siblings; + const FileElement( this.file, this.path, this.refetch, { this.highlight, + this.siblings, super.key, }); @@ -100,10 +107,7 @@ class _FileElementState extends State void _onTap() { if (widget.file.isDirectory) { - AppRoutes.openFolder( - context, - [...widget.path, widget.file.name], - ); + AppRoutes.openFolder(context, [...widget.path, widget.file.name]); return; } if (guardDemoAction(context)) return; @@ -111,12 +115,45 @@ class _FileElementState extends State confirmCancelDownload(); return; } + if (galleryMediaKindOf(widget.file.name, mimeType: widget.file.mimeType) != + null) { + _openGallery(); + return; + } startDownload( name: widget.file.name, remoteFile: RemoteFileRef.fromCacheable(widget.file), ); } + void _openGallery() { + final items = [ + for (final file in widget.siblings ?? [widget.file]) + if (!file.isDirectory) ?_galleryItem(file), + ]; + AppRoutes.openMediaGallery( + context, + source: StaticGallerySource(items), + initialKey: widget.file.path, + ); + } + + static GalleryItem? _galleryItem(CacheableFile file) { + final kind = galleryMediaKindOf(file.name, mimeType: file.mimeType); + if (kind == null) return null; + final size = file.size; + final modified = file.modifiedAt; + return GalleryItem( + file: RemoteFileRef.fromCacheable(file), + kind: kind, + eTag: file.eTag, + subtitle: [ + if (size != null) filesize(size), + if (modified != null) modified.formatDateTime(), + ].join(' · '), + ); + } + void _rename() { if (guardDemoAction(context)) return; showPromptDialog( diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 5537c13..1960d1e 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -1,12 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; -import '../../../../model/endpoint_data.dart'; -import '../../../../session/session_manager.dart'; import '../../../../utils/emoji_detection.dart'; import '../../../../utils/url_opener.dart'; +import '../widgets/chat_file_attachment.dart'; import '../widgets/highlighted_linkify.dart'; import '../widgets/message_markdown.dart'; @@ -78,36 +76,7 @@ class ChatMessage { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CachedNetworkImage( - errorWidget: (context, url, error) => Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.file_open_outlined, size: 35), - const SizedBox(width: 10), - Flexible( - child: Text( - file.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 10), - ], - ), - alignment: Alignment.center, - placeholder: (context, url) => const Padding( - padding: EdgeInsets.all(15), - child: SizedBox(width: 50, child: LinearProgressIndicator()), - ), - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - errorListener: (value) {}, - httpHeaders: SessionManager().requireNextcloud().authHeaders, - imageUrl: - '${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1', - ), + ChatFileAttachment(file: file), if (originalMessage != '{file}') ...[ SizedBox(height: 5), contentWidget, diff --git a/lib/view/pages/talk/data/talk_gallery_source.dart b/lib/view/pages/talk/data/talk_gallery_source.dart new file mode 100644 index 0000000..44d0fae --- /dev/null +++ b/lib/view/pages/talk/data/talk_gallery_source.dart @@ -0,0 +1,128 @@ +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../session/session_manager.dart'; +import '../../../../share_intent/remote_file_ref.dart'; +import '../../../../widget/media_gallery/gallery_item.dart'; +import '../../../../widget/media_gallery/gallery_source.dart'; +import '../details/shared_items_view.dart'; + +GalleryItem? galleryItemFromTalk(GetChatResponseObject message) { + final file = message.messageParameters?['file']; + if (file == null || file.path == null) return null; + final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype); + if (kind == null) return null; + return GalleryItem( + file: RemoteFileRef.fromTalk(file), + kind: kind, + messageId: message.id, + subtitle: + '${message.actorDisplayName} · ${message.sentAt.formatDateTime()}', + ); +} + +/// All images and videos of a chat, via Talk's shared-items `media` stream. +/// Opens on the tapped message and fills in around it in the background. +class TalkGallerySource extends GallerySource { + /// The share endpoint only pages backwards from the newest item, so reaching + /// an old tapped image means walking down to it; give up beyond this. + static const _maxCatchUpPages = 25; + + final String token; + final GalleryItem initial; + final int _epoch = SessionManager().sessionEpoch; + + List _items; + int? _cursor; + bool _hasOlder = false; + bool _loading = false; + bool _disposed = false; + + TalkGallerySource({required this.token, required this.initial}) + : _items = [initial] { + _catchUp(); + } + + @override + List get items => _items; + + @override + bool get hasOlder => _hasOlder && !_loading; + + bool get _stale => _disposed || !SessionManager().isCurrentSession(_epoch); + + Future _fetch(int? cursor) async => buildSharedItemsPage( + await GetSharedItems( + token, + objectType: 'media', + limit: sharedItemsPageSize, + lastKnownMessageId: cursor, + ).run(), + cursor, + ); + + Iterable _toItems(SharedItemsPage page) => + page.items.map(galleryItemFromTalk).nonNulls; + + Future _catchUp() async { + final initialId = initial.messageId!; + _loading = true; + try { + final collected = []; + int? cursor; + var hasMore = true; + var reached = false; + for (var pages = 0; hasMore && pages < _maxCatchUpPages; pages++) { + final page = await _fetch(cursor); + if (_stale) return; + collected.addAll(_toItems(page)); + cursor = page.lastKnownMessageId; + hasMore = page.hasMore; + if (page.items.any((item) => item.id <= initialId)) { + reached = true; + break; + } + } + if (reached || !hasMore) { + _items = mergeGalleryItems(_items, collected); + _cursor = cursor; + _hasOlder = hasMore; + } else { + // Too far down: skip the newer media and only page older ones. + _cursor = initialId; + _hasOlder = true; + } + } on Object { + if (_stale) return; + _cursor = initialId; + _hasOlder = true; + } finally { + _loading = false; + } + notifyListeners(); + } + + @override + Future loadOlder() async { + if (!hasOlder) return; + _loading = true; + try { + final page = await _fetch(_cursor); + if (_stale) return; + _items = mergeGalleryItems(_items, _toItems(page)); + _cursor = page.lastKnownMessageId; + _hasOlder = page.hasMore; + } on Object { + // Retried on the next page change. + } finally { + _loading = false; + } + if (!_stale) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/view/pages/talk/details/shared_items_view.dart b/lib/view/pages/talk/details/shared_items_view.dart index ae739c4..16519d7 100644 --- a/lib/view/pages/talk/details/shared_items_view.dart +++ b/lib/view/pages/talk/details/shared_items_view.dart @@ -10,14 +10,16 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_res import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../session/session_manager.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../utils/downloads/download_job.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/downloads/download_trigger.dart'; +import '../data/talk_gallery_source.dart'; -const int _sharedItemsPageSize = 20; +const int sharedItemsPageSize = 20; /// The non-media shared-item categories Talk exposes, in tab order. `media` is /// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich @@ -64,7 +66,7 @@ SharedItemsPage buildSharedItemsPage( ) { final lastGiven = response.lastGivenMessageId; final hasMore = - response.items.length >= _sharedItemsPageSize && + response.items.length >= sharedItemsPageSize && lastGiven != null && lastGiven != previousMessageId; return SharedItemsPage( @@ -79,7 +81,7 @@ SharedItemsPage buildSharedItemsPage( /// its `X-Chat-Last-Given` header. [hasMore] is a heuristic — a full page from /// the overview means there are probably older items to fetch on scroll. SharedItemsPage _seedFromOverview(List rawItems) { - final hasMore = rawItems.length >= _sharedItemsPageSize; + final hasMore = rawItems.length >= sharedItemsPageSize; final oldestId = rawItems.isEmpty ? null : rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b); @@ -106,7 +108,7 @@ class SharedItemsView extends StatefulWidget { /// screen, so opening this view needs no round-trip. static Future prefetchOverview( String token, - ) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run(); + ) => GetSharedItemsOverview(token, limit: sharedItemsPageSize).run(); @override State createState() => _SharedItemsViewState(); @@ -362,7 +364,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab> final response = await GetSharedItems( widget.token, objectType: widget.objectType, - limit: _sharedItemsPageSize, + limit: sharedItemsPageSize, lastKnownMessageId: _lastKnownMessageId, ).run(); if (!mounted) return; @@ -445,6 +447,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab> ), delegate: SliverChildBuilderDelegate( (context, index) => _SharedItemTile( + token: widget.token, item: _items[index], layout: _SharedItemsLayout.grid, ), @@ -456,6 +459,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab> SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _SharedItemTile( + token: widget.token, item: _items[index], layout: _SharedItemsLayout.list, ), @@ -470,10 +474,15 @@ class _SharedItemsTabState extends State<_SharedItemsTab> } class _SharedItemTile extends StatefulWidget { + final String token; final GetChatResponseObject item; final _SharedItemsLayout layout; - const _SharedItemTile({required this.item, required this.layout}); + const _SharedItemTile({ + required this.token, + required this.item, + required this.layout, + }); @override State<_SharedItemTile> createState() => _SharedItemTileState(); @@ -502,12 +511,18 @@ class _SharedItemTileState extends State<_SharedItemTile> if (guardDemoAction(context)) return; if (isDownloading) { confirmCancelDownload(); - } else { - startDownload( - name: _file.name, - remoteFile: RemoteFileRef.fromTalk(_file), - ); + return; } + final galleryItem = galleryItemFromTalk(widget.item); + if (galleryItem != null) { + AppRoutes.openMediaGallery( + context, + source: TalkGallerySource(token: widget.token, initial: galleryItem), + initialKey: galleryItem.key, + ); + return; + } + startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file)); } String get _previewUrl => diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 3f33521..c153c42 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/downloads/download_job.dart'; @@ -14,6 +15,7 @@ import '../../../../widget/demo_restricted.dart'; import '../../../../widget/downloads/download_trigger.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; +import '../data/talk_gallery_source.dart'; import 'answer_reference.dart'; import 'bubble.dart'; import 'chat_bubble_poll.dart'; @@ -192,9 +194,21 @@ class _ChatBubbleState extends State if (guardDemoAction(context)) return; if (isDownloading) { confirmCancelDownload(); - } else { - _startFileDownload(); + return; } + final galleryItem = galleryItemFromTalk(widget.bubbleData); + if (galleryItem != null) { + AppRoutes.openMediaGallery( + context, + source: TalkGallerySource( + token: widget.chatData.token, + initial: galleryItem, + ), + initialKey: galleryItem.key, + ); + return; + } + _startFileDownload(); } void _updateMessage(BuildContext context) { diff --git a/lib/view/pages/talk/widgets/chat_file_attachment.dart b/lib/view/pages/talk/widgets/chat_file_attachment.dart new file mode 100644 index 0000000..0b31a3b --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_file_attachment.dart @@ -0,0 +1,149 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../model/endpoint_data.dart'; +import '../../../../session/session_manager.dart'; +import '../../../../widget/media_gallery/gallery_item.dart'; +import '../../files/data/file_type_icon.dart'; + +/// File attachment in a chat bubble. Only what the media gallery pages through +/// renders as a plain picture; other previews (documents, audio covers) carry +/// a name strip so they don't pass for photos. +class ChatFileAttachment extends StatelessWidget { + final RichObjectString file; + + const ChatFileAttachment({required this.file, super.key}); + + @override + Widget build(BuildContext context) { + final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype); + return CachedNetworkImage( + imageUrl: + '${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1', + httpHeaders: SessionManager().requireNextcloud().authHeaders, + alignment: Alignment.center, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (_) {}, + placeholder: (context, url) => const Padding( + padding: EdgeInsets.all(15), + child: SizedBox(width: 50, child: LinearProgressIndicator()), + ), + imageBuilder: switch (kind) { + GalleryMediaKind.image => null, + GalleryMediaKind.video => (context, image) => Stack( + alignment: Alignment.center, + children: [ + Image(image: image), + const _PlayBadge(), + ], + ), + null => (context, image) => Stack( + children: [ + Image(image: image), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _NameStrip(name: file.name), + ), + ], + ), + }, + errorWidget: (context, url, error) => kind == GalleryMediaKind.video + ? const _VideoPlaceholder() + : _FileRow(name: file.name), + ); + } +} + +class _PlayBadge extends StatelessWidget { + const _PlayBadge(); + + @override + Widget build(BuildContext context) => const DecoratedBox( + decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), + child: Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.play_arrow, color: Colors.white, size: 28), + ), + ); +} + +/// Same footprint as a typical preview, so videos without a server thumbnail +/// still read as media. +class _VideoPlaceholder extends StatelessWidget { + const _VideoPlaceholder(); + + @override + Widget build(BuildContext context) => Container( + width: 130, + height: 90, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: const Stack( + alignment: Alignment.center, + children: [ + Icon(Icons.movie_outlined, color: Colors.white38, size: 56), + _PlayBadge(), + ], + ), + ); +} + +class _NameStrip extends StatelessWidget { + final String name; + + const _NameStrip({required this.name}); + + @override + Widget build(BuildContext context) => ColoredBox( + color: Colors.black54, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + child: Row( + children: [ + Icon(iconForFileName(name), size: 14, color: Colors.white), + const SizedBox(width: 4), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ], + ), + ), + ); +} + +class _FileRow extends StatelessWidget { + final String name; + + const _FileRow({required this.name}); + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.file_open_outlined, size: 35), + const SizedBox(width: 10), + Flexible( + child: Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), + ], + ); +} diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 266d626..26f74c0 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -3,14 +3,12 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:share_plus/share_plus.dart'; import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; @@ -18,16 +16,15 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/downloads/download_manager.dart'; import '../utils/screen_bound_image.dart'; import 'app_progress_indicator.dart'; -import 'async_action_button.dart'; import 'centered_leading.dart'; import 'downloads/stale_download_guard.dart'; import 'file_viewer/code_line.dart'; import 'file_viewer/deferred_pdf_viewer.dart'; import 'file_viewer/file_kind.dart'; +import 'file_viewer/local_file_actions.dart'; import 'file_viewer/media_player.dart'; import 'file_viewer/unknown_preview_block.dart'; import 'info_dialog.dart'; -import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { final String path; @@ -137,54 +134,11 @@ class _FileViewerState extends State { break; case FileViewingActions.share: if (!_ensureLocalFile()) return; - unawaited( - runWithErrorDialog( - context, - () => SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context), - ), - ), - ), - ); + unawaited(shareLocalFile(context, widget.path)); break; case FileViewingActions.save: if (!_ensureLocalFile()) return; - try { - final source = File(widget.path); - final size = await source.length(); - // file_picker has no path/stream save API, so the whole file - // gets loaded into RAM. Cap big media; user falls back to share. - const maxBytes = 200 * 1024 * 1024; - if (size > maxBytes) { - if (!mounted) return; - InfoDialog.show( - context, - 'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), ' - 'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.', - title: 'Speichern nicht möglich', - ); - return; - } - final bytes = await source.readAsBytes(); - final saved = await FilePicker.saveFile( - fileName: _fileName, - bytes: bytes, - ); - if (!mounted) return; - if (saved != null) { - InfoDialog.show(context, 'Datei gespeichert.'); - } - } on Object catch (e) { - if (!mounted) return; - InfoDialog.show( - context, - 'Speichern fehlgeschlagen: $e', - copyable: true, - title: 'Fehler', - ); - } + await saveLocalFile(context, widget.path); break; } } diff --git a/lib/widget/file_viewer/file_kind.dart b/lib/widget/file_viewer/file_kind.dart index 417db0c..8a793d4 100644 --- a/lib/widget/file_viewer/file_kind.dart +++ b/lib/widget/file_viewer/file_kind.dart @@ -58,6 +58,29 @@ const Set _textExtensions = { 'srt', 'vtt', }; +/// Further raster formats the gallery shows. They decode via the platform +/// codecs (HEIC/AVIF) or are JPEG/ICO under another name. SVG and TIFF stay +/// out: without a server preview only the file viewer can render them. +const Set _galleryOnlyImageExtensions = { + 'heic', + 'heif', + 'avif', + 'jfif', + 'jpe', + 'ico', +}; + +/// Extension-only check (no content sniff) for files the media gallery can +/// page through — the fallback when no MIME type is known. +bool isGalleryImageName(String name) { + final ext = name.split('.').last.toLowerCase(); + return _imageExtensions.contains(ext) || + _galleryOnlyImageExtensions.contains(ext); +} + +bool isGalleryVideoName(String name) => + _videoExtensions.contains(name.split('.').last.toLowerCase()); + /// Detects the [FileKind] of the file at [path] from its extension, falling /// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions. Future detectFileKind(String path) async { diff --git a/lib/widget/file_viewer/local_file_actions.dart b/lib/widget/file_viewer/local_file_actions.dart new file mode 100644 index 0000000..cefd183 --- /dev/null +++ b/lib/widget/file_viewer/local_file_actions.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../async_action_button.dart'; +import '../info_dialog.dart'; +import '../share_position_origin.dart'; + +/// "Teilen"/"Speichern" for a file that is on disk, shared by the file viewer +/// and the media gallery. +Future shareLocalFile(BuildContext context, String path) => + runWithErrorDialog( + context, + () => SharePlus.instance.share( + ShareParams( + files: [XFile(path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + ), + ); + +Future saveLocalFile(BuildContext context, String path) async { + try { + final source = File(path); + final size = await source.length(); + // file_picker has no path/stream save API, so the whole file + // gets loaded into RAM. Cap big media; user falls back to share. + const maxBytes = 200 * 1024 * 1024; + if (size > maxBytes) { + if (!context.mounted) return; + InfoDialog.show( + context, + 'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), ' + 'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.', + title: 'Speichern nicht möglich', + ); + return; + } + final bytes = await source.readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: path.split('/').last, + bytes: bytes, + ); + if (!context.mounted) return; + if (saved != null) { + InfoDialog.show(context, 'Datei gespeichert.'); + } + } on Object catch (e) { + if (!context.mounted) return; + InfoDialog.show( + context, + 'Speichern fehlgeschlagen: $e', + copyable: true, + title: 'Fehler', + ); + } +} diff --git a/lib/widget/file_viewer/media_player.dart b/lib/widget/file_viewer/media_player.dart index 1e461ea..08183ff 100644 --- a/lib/widget/file_viewer/media_player.dart +++ b/lib/widget/file_viewer/media_player.dart @@ -13,11 +13,13 @@ class MediaPlayer extends StatefulWidget { final String path; final bool isAudio; final String? filename; + final bool autoPlay; const MediaPlayer({ super.key, required this.path, required this.isAudio, this.filename, + this.autoPlay = false, }); @override @@ -57,7 +59,7 @@ class _MediaPlayerState extends State { _video = controller; _chewie = ChewieController( videoPlayerController: controller, - autoPlay: false, + autoPlay: widget.autoPlay, looping: false, allowFullScreen: true, allowPlaybackSpeedChanging: true, diff --git a/lib/widget/media_gallery/gallery_cache.dart b/lib/widget/media_gallery/gallery_cache.dart new file mode 100644 index 0000000..7a756d2 --- /dev/null +++ b/lib/widget/media_gallery/gallery_cache.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import '../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../model/endpoint_data.dart'; +import '../../session/session_manager.dart'; +import '../../utils/downloads/download_manager.dart'; +import '../../utils/screen_bound_image.dart'; +import 'gallery_item.dart'; + +/// Server-rendered preview edge for the gallery; Nextcloud derives it from the +/// max preview it already keeps for the thumbnails. +const int _previewSize = 2048; + +/// Originals the gallery loaded on its own — outside [DownloadManager], so no +/// notification or tray entry. Wiped whenever an account stops being active. +class GalleryOriginals extends ChangeNotifier { + GalleryOriginals._(); + static final GalleryOriginals instance = GalleryOriginals._(); + + static final CacheManager _images = CacheManager( + Config( + 'galleryOriginals', + stalePeriod: const Duration(days: 14), + maxNrOfCacheObjects: 150, + ), + ); + + // Videos are large; keep only a handful around. + static final CacheManager _videos = CacheManager( + Config( + 'galleryVideos', + stalePeriod: const Duration(days: 3), + maxNrOfCacheObjects: 10, + ), + ); + + final Map _flights = {}; + + static CacheManager _managerFor(GalleryItem item) => + item.isVideo ? _videos : _images; + + static String _cacheKey(GalleryItem item) => + '${SessionManager().requireNextcloud().username}:${item.file.path}' + ':${item.eTag ?? ''}'; + + static String _webdavUrl(String remotePath) => + '${WebdavApi.buildWebdavUrl()}' + '${Uri.encodeComponent(remotePath).replaceAll('%2F', '/')}'; + + /// Download progress (0–1, null while the size is unknown) of a running + /// original download, or null when none runs. + ValueListenable? progressOf(GalleryItem item) => + _flights[_cacheKey(item)]?.progress; + + Future cached(GalleryItem item) async { + final info = await _managerFor(item).getFileFromCache(_cacheKey(item)); + final file = info?.file; + return file != null && file.existsSync() ? file : null; + } + + /// Loads the original once; parallel callers share the same download. + Future load(GalleryItem item) { + final key = _cacheKey(item); + final running = _flights[key]; + if (running != null) return running.completer.future; + + final flight = _Flight(); + _flights[key] = flight; + notifyListeners(); + final epoch = SessionManager().sessionEpoch; + final manager = _managerFor(item); + flight.subscription = manager + .getFileStream( + _webdavUrl(item.file.path), + key: key, + headers: SessionManager().requireNextcloud().authHeaders, + withProgress: true, + ) + .listen( + (response) { + if (response is DownloadProgress) { + flight.progress.value = response.progress; + } else if (response is FileInfo) { + if (!SessionManager().isCurrentSession(epoch)) { + unawaited(manager.removeFile(key)); + _finish(key, error: StateError('Konto gewechselt')); + return; + } + _finish(key, file: response.file); + } + }, + onError: (Object e) => _finish(key, error: e), + cancelOnError: true, + ); + return flight.completer.future; + } + + void _finish(String key, {File? file, Object? error}) { + final flight = _flights.remove(key); + if (flight == null) return; + unawaited(flight.cancel()); + if (file != null) { + flight.completer.complete(file); + } else { + flight.completer.completeError( + error ?? StateError('Download fehlgeschlagen'), + ); + } + notifyListeners(); + scheduleMicrotask(flight.progress.dispose); + } + + /// Copies the original under its real file name (into the downloads folder, + /// which [SessionWipe] clears) — share sheets and "Öffnen mit" need the name + /// and extension, the cache file has neither. + Future namedCopy(GalleryItem item) async { + final source = await cached(item) ?? await load(item); + final dir = await DownloadManager.downloadsDirectory(); + await dir.create(recursive: true); + final target = File('${dir.path}/${item.file.name}'); + await source.copy(target.path); + return target.path; + } + + Future clear() async { + for (final key in _flights.keys.toList()) { + _finish(key, error: StateError('Abgebrochen')); + } + await _images.emptyCache(); + await _videos.emptyCache(); + } +} + +class _Flight { + final Completer completer = Completer(); + final ValueNotifier progress = ValueNotifier(null); + StreamSubscription? subscription; + + Future cancel() async => subscription?.cancel(); +} + +/// Screen-sized server preview. One instance per item and size, so the +/// viewer's precache and the page's image hit the same cache entry. +ImageProvider galleryPreviewProvider(BuildContext context, GalleryItem item) { + final fileId = item.file.fileId; + final url = fileId != null + ? '${EndpointData().nextcloud().origin()}/index.php/core/preview' + '?fileId=$fileId&x=$_previewSize&y=$_previewSize&a=1' + : '${EndpointData().nextcloud().origin()}/index.php/core/preview.png' + '?file=${Uri.encodeQueryComponent(item.file.path)}' + '&x=$_previewSize&y=$_previewSize&a=1'; + return screenBoundImage( + context, + CachedNetworkImageProvider( + url, + headers: SessionManager().requireNextcloud().authHeaders, + ), + ); +} diff --git a/lib/widget/media_gallery/gallery_image_page.dart b/lib/widget/media_gallery/gallery_image_page.dart new file mode 100644 index 0000000..9a5959f --- /dev/null +++ b/lib/widget/media_gallery/gallery_image_page.dart @@ -0,0 +1,403 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../../utils/screen_bound_image.dart'; +import '../app_progress_indicator.dart'; +import 'gallery_cache.dart'; +import 'gallery_item.dart'; + +/// One image in the gallery: the server preview first, the original on top +/// once the user rests on the page or zooms in. +class GalleryImagePage extends StatefulWidget { + final GalleryItem item; + final bool isActive; + final ValueListenable rotateSignal; + + const GalleryImagePage({ + required this.item, + required this.isActive, + required this.rotateSignal, + super.key, + }); + + @override + State createState() => _GalleryImagePageState(); +} + +class _GalleryImagePageState extends State { + // Short enough to feel instant, long enough that flicking through a chat + // does not pull every original. + static const _restDelay = Duration(milliseconds: 400); + static const _fadeDuration = Duration(milliseconds: 200); + static const _doubleTapZoom = 2.5; + + /// Max zoom reaches this many times the image's native pixel density + /// (1 image pixel = 1 screen pixel), so sharp photos zoom far in. + static const _beyondNativeZoom = 3.0; + + /// Floor for small images, which would otherwise barely zoom at all. + static const _minMaxZoom = 4.0; + + final PhotoViewController _controller = PhotoViewController(); + final PhotoViewScaleStateController _scaleState = + PhotoViewScaleStateController(); + final List<(ImageStream, ImageStreamListener)> _streams = []; + StreamSubscription? _zoomSub; + Timer? _restTimer; + Timer? _hidePreviewTimer; + + ImageProvider? _preview; + ImageProvider? _original; + Size? _imageSize; + + /// Decoded pixel size of the original, once shown. + Size? _originalSize; + bool _previewFailed = false; + bool _originalFailed = false; + bool _loadingOriginal = false; + bool _originalReady = false; + bool _hidePreview = false; + ValueListenable? _progress; + late int _rotationsSeen; + + bool get _isZoomed => switch (_scaleState.scaleState) { + PhotoViewScaleState.zoomedIn || + PhotoViewScaleState.covering || + PhotoViewScaleState.originalSize => true, + PhotoViewScaleState.initial || PhotoViewScaleState.zoomedOut => false, + }; + + @override + void initState() { + super.initState(); + _rotationsSeen = widget.rotateSignal.value; + widget.rotateSignal.addListener(_onRotate); + GalleryOriginals.instance.addListener(_onFlightsChanged); + _progress = GalleryOriginals.instance.progressOf(widget.item); + // Pinch zoom only updates the scale state silently, so listen to the + // controller instead of scaleStateChangedCallback. + _zoomSub = _controller.outputStateStream.listen((_) { + if (_isZoomed) _loadOriginal(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_preview != null) return; + final preview = galleryPreviewProvider(context, widget.item); + _preview = preview; + _listen( + preview, + onImage: _adoptSize, + onError: () { + setState(() => _previewFailed = true); + _loadOriginal(); + }, + ); + unawaited(_useCachedOriginal()); + } + + @override + void didUpdateWidget(covariant GalleryImagePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive == oldWidget.isActive) return; + if (widget.isActive) { + _scheduleRest(); + } else { + _restTimer?.cancel(); + _scaleState.reset(); + } + } + + @override + void dispose() { + widget.rotateSignal.removeListener(_onRotate); + GalleryOriginals.instance.removeListener(_onFlightsChanged); + _restTimer?.cancel(); + _hidePreviewTimer?.cancel(); + unawaited(_zoomSub?.cancel()); + for (final (stream, listener) in _streams) { + stream.removeListener(listener); + } + _controller.dispose(); + _scaleState.dispose(); + super.dispose(); + } + + Future _useCachedOriginal() async { + final cached = await GalleryOriginals.instance.cached(widget.item); + if (!mounted || _original != null) return; + if (cached != null) { + _setOriginal(cached); + } else if (widget.item.needsOriginal) { + _loadOriginal(); + } else { + _scheduleRest(); + } + } + + void _scheduleRest() { + if (!widget.isActive || _original != null || _originalFailed) return; + _restTimer?.cancel(); + _restTimer = Timer(_restDelay, _loadOriginal); + } + + void _loadOriginal() { + if (_original != null || _loadingOriginal || _originalFailed) return; + _restTimer?.cancel(); + _loadingOriginal = true; + GalleryOriginals.instance + .load(widget.item) + .then( + (file) { + if (mounted) _setOriginal(file); + }, + onError: (Object _) { + if (!mounted) return; + // The preview stays; no automatic retry, or every zoom frame would + // start a new request. + setState(() { + _loadingOriginal = false; + _originalFailed = true; + }); + }, + ); + } + + void _setOriginal(File file) { + final original = screenBoundImage(context, FileImage(file), scale: 2); + setState(() { + _original = original; + _loadingOriginal = false; + }); + _listen( + original, + onImage: (size) { + _adoptSize(size); + setState(() { + _originalReady = true; + _originalSize = size; + }); + // Drop the preview after the fade so transparent images don't show + // it through. + _hidePreviewTimer = Timer(_fadeDuration, () { + if (mounted) setState(() => _hidePreview = true); + }); + }, + onError: () => setState(() { + _original = null; + _originalFailed = true; + }), + ); + } + + /// The first resolved image fixes the zoom geometry; the other one is drawn + /// into the same box, so swapping preview and original keeps the zoom. + void _adoptSize(Size size) { + if (_imageSize != null) return; + setState(() => _imageSize = size); + } + + void _listen( + ImageProvider provider, { + required void Function(Size size) onImage, + required VoidCallback onError, + }) { + final stream = provider.resolve(createLocalImageConfiguration(context)); + late final ImageStreamListener listener; + late final (ImageStream, ImageStreamListener) entry; + // A completer throws when a listener is removed twice, so a fired + // listener also leaves the list dispose() walks. + void detach() { + stream.removeListener(listener); + _streams.remove(entry); + } + + listener = ImageStreamListener( + (info, _) { + detach(); + if (!mounted) return; + onImage( + Size(info.image.width.toDouble(), info.image.height.toDouble()), + ); + }, + onError: (_, _) { + detach(); + if (mounted) onError(); + }, + ); + entry = (stream, listener); + _streams.add(entry); + stream.addListener(listener); + } + + void _onRotate() { + final value = widget.rotateSignal.value; + final pending = value - _rotationsSeen; + _rotationsSeen = value; + if (widget.isActive && pending > 0) { + _controller.rotation += pending * pi / 2; + } + } + + void _onFlightsChanged() { + final progress = GalleryOriginals.instance.progressOf(widget.item); + if (progress != _progress && mounted) setState(() => _progress = progress); + } + + /// Double tap toggles between fitted and [_doubleTapZoom]× fitted. + static PhotoViewScaleState _scaleCycle(PhotoViewScaleState state) => + state == PhotoViewScaleState.initial + ? PhotoViewScaleState.originalSize + : PhotoViewScaleState.initial; + + @override + Widget build(BuildContext context) { + final imageSize = _imageSize; + final background = Theme.of(context).colorScheme.surface; + if (imageSize == null) { + if (_previewFailed && _originalFailed) { + return const _ImageUnavailable(); + } + return const Center(child: AppProgressIndicator.large()); + } + final preview = _preview; + final original = _original; + final progress = _progress; + return Stack( + fit: StackFit.expand, + children: [ + LayoutBuilder( + builder: (context, constraints) { + // PhotoView's "original size" is the child box itself; sizing it + // to a multiple of the fitted image makes that the double-tap + // zoom, independent of preview vs. original resolution. + final fitted = applyBoxFit( + BoxFit.contain, + imageSize, + constraints.biggest, + ).destination; + final childSize = fitted * _doubleTapZoom; + // Until the original is in, the preview's resolution bounds the + // zoom; it widens once the original arrives. + final detail = _originalSize ?? imageSize; + final nativeZoom = + detail.width / + MediaQuery.devicePixelRatioOf(context) / + fitted.width; + final maxZoom = max(_minMaxZoom, nativeZoom * _beyondNativeZoom); + return PhotoView.customChild( + controller: _controller, + scaleStateController: _scaleState, + childSize: childSize, + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.contained * maxZoom, + scaleStateCycle: _scaleCycle, + // A non-none filterQuality makes PhotoView resize only its own + // Image instead of transforming — a custom child would never + // scale. Quality is set on the images below instead. + filterQuality: FilterQuality.none, + backgroundDecoration: BoxDecoration(color: background), + child: SizedBox.fromSize( + size: childSize, + child: Stack( + fit: StackFit.expand, + children: [ + if (preview != null && !_previewFailed && !_hidePreview) + Image( + image: preview, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ), + if (original != null) + AnimatedOpacity( + opacity: _originalReady ? 1 : 0, + duration: _fadeDuration, + child: Image( + image: original, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + gaplessPlayback: true, + ), + ), + ], + ), + ), + ); + }, + ), + Positioned( + right: 16, + bottom: 16, + child: SafeArea( + child: AnimatedSwitcher( + duration: _fadeDuration, + child: progress == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => + GalleryProgressBadge(value: value), + ), + ), + ), + ), + ], + ); + } +} + +/// Small, unobtrusive ring for a running original download. +class GalleryProgressBadge extends StatelessWidget { + final double? value; + + const GalleryProgressBadge({required this.value, super.key}); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: const BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + value: value, + strokeWidth: 2, + color: Colors.white, + semanticsLabel: 'Original wird geladen', + ), + ), + ), + ); +} + +class _ImageUnavailable extends StatelessWidget { + const _ImageUnavailable(); + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.broken_image_outlined, size: 48), + const SizedBox(height: 12), + Text( + 'Bild konnte nicht geladen werden', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); +} diff --git a/lib/widget/media_gallery/gallery_item.dart b/lib/widget/media_gallery/gallery_item.dart new file mode 100644 index 0000000..33983cc --- /dev/null +++ b/lib/widget/media_gallery/gallery_item.dart @@ -0,0 +1,79 @@ +import '../../share_intent/remote_file_ref.dart'; +import '../file_viewer/file_kind.dart'; + +enum GalleryMediaKind { image, video } + +/// Single rule for "is this a picture/video": the gallery and the chat bubble +/// both use it, so whatever looks like media in a chat is also in the gallery. +GalleryMediaKind? galleryMediaKindOf(String name, {String? mimeType}) { + final mime = mimeType?.toLowerCase() ?? ''; + // Only formats the device decodes itself — image/* also covers PSD, SVG, + // TIFF …, which would hinge on a server preview. + if (_galleryImageMimes.contains(mime)) return GalleryMediaKind.image; + if (mime.startsWith('video/')) return GalleryMediaKind.video; + if (isGalleryImageName(name)) return GalleryMediaKind.image; + if (isGalleryVideoName(name)) return GalleryMediaKind.video; + return null; +} + +const Set _galleryImageMimes = { + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/x-ms-bmp', + 'image/heic', + 'image/heif', + 'image/avif', + 'image/x-icon', + 'image/vnd.microsoft.icon', +}; + +class GalleryItem { + final RemoteFileRef file; + final GalleryMediaKind kind; + + /// Talk message id: orders chat media and dedupes overlapping pages. + final int? messageId; + + /// Second line in the app bar, e.g. sender and date. + final String? subtitle; + + /// Part of the cache key so an overwritten file is not served stale. + final String? eTag; + + const GalleryItem({ + required this.file, + required this.kind, + this.messageId, + this.subtitle, + this.eTag, + }); + + String get key => messageId?.toString() ?? file.path; + + bool get isVideo => kind == GalleryMediaKind.video; + + String get _extension => file.name.split('.').last.toLowerCase(); + + /// Server previews of a GIF are a single still frame. + bool get needsOriginal => _extension == 'gif'; +} + +/// Adds [incoming] to [current], dropping duplicates by [GalleryItem.key] and +/// keeping chat media in chronological (message id) order. +List mergeGalleryItems( + List current, + Iterable incoming, +) { + final byKey = {for (final item in current) item.key: item}; + for (final item in incoming) { + byKey.putIfAbsent(item.key, () => item); + } + final merged = byKey.values.toList(); + if (merged.every((item) => item.messageId != null)) { + merged.sort((a, b) => a.messageId!.compareTo(b.messageId!)); + } + return merged; +} diff --git a/lib/widget/media_gallery/gallery_source.dart b/lib/widget/media_gallery/gallery_source.dart new file mode 100644 index 0000000..f15dce6 --- /dev/null +++ b/lib/widget/media_gallery/gallery_source.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; + +import 'gallery_item.dart'; + +/// Items the media gallery pages through. Sources that grow while the viewer +/// is open (chat history) notify; the viewer keeps the shown item in place. +abstract class GallerySource extends ChangeNotifier { + List get items; + + bool get hasOlder => false; + + /// Called when the user nears the start (oldest end) of [items]. + Future loadOlder() async {} +} + +class StaticGallerySource extends GallerySource { + @override + final List items; + + StaticGallerySource(this.items); +} diff --git a/lib/widget/media_gallery/gallery_video_page.dart b/lib/widget/media_gallery/gallery_video_page.dart new file mode 100644 index 0000000..3e936ce --- /dev/null +++ b/lib/widget/media_gallery/gallery_video_page.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../file_viewer/media_player.dart'; +import 'gallery_cache.dart'; +import 'gallery_item.dart'; + +/// A video in the gallery: thumbnail and play button; the file is only +/// downloaded on play. Leaving the page tears the player down. +class GalleryVideoPage extends StatefulWidget { + final GalleryItem item; + final bool isActive; + + const GalleryVideoPage({ + required this.item, + required this.isActive, + super.key, + }); + + @override + State createState() => _GalleryVideoPageState(); +} + +class _GalleryVideoPageState extends State { + String? _path; + bool _playing = false; + bool _failed = false; + ValueListenable? _progress; + + @override + void initState() { + super.initState(); + GalleryOriginals.instance.addListener(_onFlightsChanged); + _progress = GalleryOriginals.instance.progressOf(widget.item); + } + + @override + void didUpdateWidget(covariant GalleryVideoPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.isActive && _playing) _playing = false; + } + + @override + void dispose() { + GalleryOriginals.instance.removeListener(_onFlightsChanged); + super.dispose(); + } + + void _onFlightsChanged() { + final progress = GalleryOriginals.instance.progressOf(widget.item); + if (progress != _progress && mounted) setState(() => _progress = progress); + } + + Future _play() async { + setState(() => _failed = false); + try { + final file = + await GalleryOriginals.instance.cached(widget.item) ?? + await GalleryOriginals.instance.load(widget.item); + if (!mounted) return; + setState(() { + _path = file.path; + _playing = widget.isActive; + }); + } on Object { + if (mounted) setState(() => _failed = true); + } + } + + @override + Widget build(BuildContext context) { + final path = _path; + if (_playing && path != null) { + return MediaPlayer( + key: ValueKey(path), + path: path, + isAudio: false, + autoPlay: true, + ); + } + final progress = _progress; + return Stack( + fit: StackFit.expand, + children: [ + Image( + image: galleryPreviewProvider(context, widget.item), + fit: BoxFit.contain, + gaplessPlayback: true, + errorBuilder: (_, _, _) => + const Center(child: Icon(Icons.movie_outlined, size: 96)), + ), + Center( + child: progress != null + ? ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => _PlayCircle( + child: CircularProgressIndicator( + value: value, + color: Colors.white, + strokeWidth: 3, + semanticsLabel: 'Video wird geladen', + ), + ), + ) + : _PlayCircle( + onTap: () => unawaited(_play()), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 40, + semanticLabel: 'Abspielen', + ), + ), + ), + if (_failed) + Positioned( + left: 24, + right: 24, + bottom: 32, + child: SafeArea( + child: Text( + 'Video konnte nicht geladen werden. Tippe erneut auf Abspielen.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ], + ); + } +} + +class _PlayCircle extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + + const _PlayCircle({required this.child, this.onTap}); + + @override + Widget build(BuildContext context) => Material( + color: Colors.black54, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: 72, + height: 72, + child: Center(child: SizedBox(width: 40, height: 40, child: child)), + ), + ), + ); +} diff --git a/lib/widget/media_gallery/media_gallery_viewer.dart b/lib/widget/media_gallery/media_gallery_viewer.dart new file mode 100644 index 0000000..63dc91a --- /dev/null +++ b/lib/widget/media_gallery/media_gallery_viewer.dart @@ -0,0 +1,267 @@ +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, + ), + ); + }, + ), + ), + ); +} diff --git a/test/widget/media_gallery_test.dart b/test/widget/media_gallery_test.dart new file mode 100644 index 0000000..ad6d8fd --- /dev/null +++ b/test/widget/media_gallery_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/share_intent/remote_file_ref.dart'; +import 'package:marianum_mobile/widget/media_gallery/gallery_item.dart'; + +GalleryItem _chat(int id, {String name = 'a.jpg'}) => GalleryItem( + file: RemoteFileRef(path: 'Talk/$name', name: name), + kind: GalleryMediaKind.image, + messageId: id, +); + +GalleryItem _file(String name) => GalleryItem( + file: RemoteFileRef(path: '/Fotos/$name', name: name), + kind: GalleryMediaKind.image, +); + +void main() { + group('galleryMediaKindOf', () { + test('classifies images and videos case-insensitively', () { + expect(galleryMediaKindOf('Foto.JPG'), GalleryMediaKind.image); + expect(galleryMediaKindOf('scan.heic'), GalleryMediaKind.image); + expect(galleryMediaKindOf('clip.mov'), GalleryMediaKind.video); + }); + + test('prefers the MIME type over the extension', () { + expect( + galleryMediaKindOf('IMG_0042', mimeType: 'image/jpeg'), + GalleryMediaKind.image, + ); + expect( + galleryMediaKindOf('aufnahme.bin', mimeType: 'video/mp4'), + GalleryMediaKind.video, + ); + expect(galleryMediaKindOf('scan.jfif'), GalleryMediaKind.image); + }); + + test('skips image types without a usable preview', () { + expect( + galleryMediaKindOf( + 'entwurf.psd', + mimeType: 'image/vnd.adobe.photoshop', + ), + isNull, + ); + }); + + test('ignores everything else', () { + expect(galleryMediaKindOf('Arbeitsblatt.pdf'), isNull); + expect(galleryMediaKindOf('logo.svg'), isNull); + expect(galleryMediaKindOf('scan.tiff', mimeType: 'image/tiff'), isNull); + expect(galleryMediaKindOf('README'), isNull); + }); + }); + + group('mergeGalleryItems', () { + test('sorts chat media chronologically and drops duplicates', () { + final merged = mergeGalleryItems( + [_chat(50)], + [_chat(70), _chat(50), _chat(30)], + ); + expect(merged.map((i) => i.messageId), [30, 50, 70]); + }); + + test('keeps the existing instance for a known key', () { + final initial = _chat(50); + final merged = mergeGalleryItems([initial], [_chat(50, name: 'b.jpg')]); + expect(merged.single, same(initial)); + }); + + test('keeps folder order untouched', () { + final merged = mergeGalleryItems( + [_file('c.jpg'), _file('a.jpg')], + [_file('b.jpg')], + ); + expect(merged.map((i) => i.file.name), ['c.jpg', 'a.jpg', 'b.jpg']); + }); + }); + + test('GIFs always need the original', () { + expect(_chat(1, name: 'lol.GIF').needsOriginal, isTrue); + expect(_chat(1).needsOriginal, isFalse); + }); +}