added a swipeable media gallery for chat and folder images and videos with server previews, on-demand originals and zoom, and marked document previews in chat so they no longer pass for photos
This commit is contained in:
1 parent
952f916231
commit
aa176c90e3
22 files changed
+1658
-103
No files matched your search
@@ -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<String, dynamic> json) =>
|
||||
_$RichObjectStringFromJson(json);
|
||||
|
||||
@@ -135,6 +135,7 @@ RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> 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<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'link': instance.link,
|
||||
'mimetype': instance.mimetype,
|
||||
};
|
||||
|
||||
const _$RichObjectStringObjectTypeEnumMap = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<CacheableFile>? siblings;
|
||||
|
||||
const FileElement(
|
||||
this.file,
|
||||
this.path,
|
||||
this.refetch, {
|
||||
this.highlight,
|
||||
this.siblings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -100,10 +107,7 @@ class _FileElementState extends State<FileElement>
|
||||
|
||||
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<FileElement>
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GalleryItem> _items;
|
||||
int? _cursor;
|
||||
bool _hasOlder = false;
|
||||
bool _loading = false;
|
||||
bool _disposed = false;
|
||||
|
||||
TalkGallerySource({required this.token, required this.initial})
|
||||
: _items = [initial] {
|
||||
_catchUp();
|
||||
}
|
||||
|
||||
@override
|
||||
List<GalleryItem> get items => _items;
|
||||
|
||||
@override
|
||||
bool get hasOlder => _hasOlder && !_loading;
|
||||
|
||||
bool get _stale => _disposed || !SessionManager().isCurrentSession(_epoch);
|
||||
|
||||
Future<SharedItemsPage> _fetch(int? cursor) async => buildSharedItemsPage(
|
||||
await GetSharedItems(
|
||||
token,
|
||||
objectType: 'media',
|
||||
limit: sharedItemsPageSize,
|
||||
lastKnownMessageId: cursor,
|
||||
).run(),
|
||||
cursor,
|
||||
);
|
||||
|
||||
Iterable<GalleryItem> _toItems(SharedItemsPage page) =>
|
||||
page.items.map(galleryItemFromTalk).nonNulls;
|
||||
|
||||
Future<void> _catchUp() async {
|
||||
final initialId = initial.messageId!;
|
||||
_loading = true;
|
||||
try {
|
||||
final collected = <GalleryItem>[];
|
||||
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<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<GetChatResponseObject> 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<GetSharedItemsOverviewResponse> prefetchOverview(
|
||||
String token,
|
||||
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
|
||||
) => GetSharedItemsOverview(token, limit: sharedItemsPageSize).run();
|
||||
|
||||
@override
|
||||
State<SharedItemsView> 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 =>
|
||||
|
||||
@@ -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<ChatBubble>
|
||||
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) {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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<FileViewer> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,29 @@ const Set<String> _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<String> _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<FileKind> detectFileKind(String path) async {
|
||||
|
||||
@@ -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<void> shareLocalFile(BuildContext context, String path) =>
|
||||
runWithErrorDialog(
|
||||
context,
|
||||
() => SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MediaPlayer> {
|
||||
_video = controller;
|
||||
_chewie = ChewieController(
|
||||
videoPlayerController: controller,
|
||||
autoPlay: false,
|
||||
autoPlay: widget.autoPlay,
|
||||
looping: false,
|
||||
allowFullScreen: true,
|
||||
allowPlaybackSpeedChanging: true,
|
||||
|
||||
@@ -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<String, _Flight> _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<double?>? progressOf(GalleryItem item) =>
|
||||
_flights[_cacheKey(item)]?.progress;
|
||||
|
||||
Future<File?> 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<File> 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<String> 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<void> clear() async {
|
||||
for (final key in _flights.keys.toList()) {
|
||||
_finish(key, error: StateError('Abgebrochen'));
|
||||
}
|
||||
await _images.emptyCache();
|
||||
await _videos.emptyCache();
|
||||
}
|
||||
}
|
||||
|
||||
class _Flight {
|
||||
final Completer<File> completer = Completer<File>();
|
||||
final ValueNotifier<double?> progress = ValueNotifier(null);
|
||||
StreamSubscription<FileResponse>? subscription;
|
||||
|
||||
Future<void> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<int> rotateSignal;
|
||||
|
||||
const GalleryImagePage({
|
||||
required this.item,
|
||||
required this.isActive,
|
||||
required this.rotateSignal,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GalleryImagePage> createState() => _GalleryImagePageState();
|
||||
}
|
||||
|
||||
class _GalleryImagePageState extends State<GalleryImagePage> {
|
||||
// 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<PhotoViewControllerValue>? _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<double?>? _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<void> _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<double?>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<String> _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<GalleryItem> mergeGalleryItems(
|
||||
List<GalleryItem> current,
|
||||
Iterable<GalleryItem> 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;
|
||||
}
|
||||
@@ -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<GalleryItem> get items;
|
||||
|
||||
bool get hasOlder => false;
|
||||
|
||||
/// Called when the user nears the start (oldest end) of [items].
|
||||
Future<void> loadOlder() async {}
|
||||
}
|
||||
|
||||
class StaticGallerySource extends GallerySource {
|
||||
@override
|
||||
final List<GalleryItem> items;
|
||||
|
||||
StaticGallerySource(this.items);
|
||||
}
|
||||
@@ -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<GalleryVideoPage> createState() => _GalleryVideoPageState();
|
||||
}
|
||||
|
||||
class _GalleryVideoPageState extends State<GalleryVideoPage> {
|
||||
String? _path;
|
||||
bool _playing = false;
|
||||
bool _failed = false;
|
||||
ValueListenable<double?>? _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<void> _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<double?>(
|
||||
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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<MediaGalleryViewer> createState() => _MediaGalleryViewerState();
|
||||
}
|
||||
|
||||
class _MediaGalleryViewerState extends State<MediaGalleryViewer> {
|
||||
/// Neighbours whose previews are fetched ahead, per side.
|
||||
static const _precacheRange = 2;
|
||||
|
||||
late final PageController _pageController;
|
||||
final ValueNotifier<int> _rotateSignal = ValueNotifier(0);
|
||||
late String _anchorKey;
|
||||
late int _index;
|
||||
bool _busy = false;
|
||||
|
||||
List<GalleryItem> 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<void> _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<String>) 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user