From 75080a2c499d5ea50842a97aa1c30e3ec1662187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 19 Jul 2026 10:58:02 +0200 Subject: [PATCH] implemented shared items view for Talk chats with tabbed categories and on-scroll pagination --- .../get_shared_items/get_shared_items.dart | 40 ++ .../get_shared_items_overview.dart | 28 + .../get_shared_items_overview_response.dart | 32 + .../get_shared_items_response.dart | 35 + lib/view/pages/talk/details/chat_info.dart | 33 +- .../pages/talk/details/shared_items_view.dart | 680 ++++++++++++++++++ 6 files changed, 846 insertions(+), 2 deletions(-) create mode 100644 lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart create mode 100644 lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart create mode 100644 lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart create mode 100644 lib/api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart create mode 100644 lib/view/pages/talk/details/shared_items_view.dart diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart new file mode 100644 index 0000000..7624d85 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart @@ -0,0 +1,40 @@ +import 'package:http/http.dart' as http; + +import '../../nextcloud_ocs.dart'; +import '../talk_api.dart'; +import 'get_shared_items_response.dart'; + +/// Fetches the messages that shared an item of a given [objectType] in a chat +/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using +/// the `X-Chat-Last-Given` response header (see +/// [GetSharedItemsResponse.lastGivenMessageId]). +/// +/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`, +/// `deckcard`, `recording`, `other`. +class GetSharedItems extends TalkApi { + GetSharedItems( + String token, { + required String objectType, + int limit = 20, + int? lastKnownMessageId, + }) : super( + 'v1/chat/$token/share', + null, + getParameters: { + 'objectType': objectType, + 'limit': limit, + 'lastKnownMessageId': ?lastKnownMessageId, + }, + ); + + @override + GetSharedItemsResponse assemble(String raw) => + GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart new file mode 100644 index 0000000..d23a113 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart' as http; + +import '../../nextcloud_ocs.dart'; +import '../talk_api.dart'; +import 'get_shared_items_overview_response.dart'; + +/// Fetches the latest shared items of every type at once (Talk's +/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to +/// show and to seed their first page. [limit] caps the items returned per type. +class GetSharedItemsOverview extends TalkApi { + GetSharedItemsOverview(String token, {int limit = 20}) + : super( + 'v1/chat/$token/share/overview', + null, + getParameters: {'limit': limit}, + ); + + @override + GetSharedItemsOverviewResponse assemble(String raw) => + GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart new file mode 100644 index 0000000..4546eb5 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart @@ -0,0 +1,32 @@ +import '../../../api_response.dart'; +import '../chat/get_chat_response.dart'; + +/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared +/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`, +/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the +/// message structure. +class GetSharedItemsOverviewResponse extends ApiResponse { + final Map> itemsByType; + + GetSharedItemsOverviewResponse(this.itemsByType); + + factory GetSharedItemsOverviewResponse.fromOcs(Map ocs) { + final data = ocs['data']; + final result = >{}; + if (data is Map) { + for (final entry in data.entries) { + final value = entry.value; + final raw = switch (value) { + List list => list, + Map map => map.values, + _ => const [], + }; + result[entry.key] = raw + .whereType>() + .map(GetChatResponseObject.fromJson) + .toList(); + } + } + return GetSharedItemsOverviewResponse(result); + } +} diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart new file mode 100644 index 0000000..fe5fa3b --- /dev/null +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart @@ -0,0 +1,35 @@ +import '../../../api_response.dart'; +import '../chat/get_chat_response.dart'; + +/// Response of Talk's shared-items endpoints. The message objects carry the +/// same structure as regular chat messages, so we reuse [GetChatResponseObject] +/// (the shared file lives in `messageParameters['file']`). +/// +/// The server returns `data` either as a list or as a message-id-keyed map +/// depending on version; [fromOcs] normalises both to a list. +class GetSharedItemsResponse extends ApiResponse { + final List items; + + GetSharedItemsResponse(this.items); + + factory GetSharedItemsResponse.fromOcs(Map ocs) { + final data = ocs['data']; + final raw = switch (data) { + List list => list, + Map map => map.values, + _ => const [], + }; + final items = raw + .whereType>() + .map(GetChatResponseObject.fromJson) + .toList(); + return GetSharedItemsResponse(items); + } + + /// Offset for the next page, taken from the `X-Chat-Last-Given` header. + /// Null when the header is absent (older server) — treat that as "stop". + int? get lastGivenMessageId { + final value = headers?['x-chat-last-given']; + return value == null ? null : int.tryParse(value); + } +} diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 0578334..776b2ea 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_cache.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -11,10 +12,10 @@ import '../../../../widget/async_action_button.dart'; import '../../../../widget/avatar_actions_sheet.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/large_profile_picture_view.dart'; -import '../../../../widget/loading_spinner.dart'; import '../../../../widget/user_avatar.dart'; import '../talk_navigator.dart'; import 'participants_list_view.dart'; +import 'shared_items_view.dart'; class ChatInfo extends StatefulWidget { final GetRoomResponseObject room; @@ -26,6 +27,7 @@ class ChatInfo extends StatefulWidget { class _ChatInfoState extends State { GetParticipantsResponse? participants; + GetSharedItemsOverviewResponse? _sharesOverview; late bool _isFavorite; int _avatarVersion = 0; bool _avatarBusy = false; @@ -42,6 +44,16 @@ class _ChatInfoState extends State { }); }, ); + _preloadShares(); + } + + Future _preloadShares() async { + try { + final overview = await SharedItemsView.prefetchOverview(widget.room.token); + if (mounted) setState(() => _sharesOverview = overview); + } catch (_) { + // Best-effort: the shares view loads on demand if the preload fails. + } } void _refreshList() => context.read().refresh(); @@ -187,7 +199,15 @@ class _ChatInfoState extends State { ), const SizedBox(height: 30), if (participants == null) - const Center(child: LoadingSpinner()) + const ListTile( + leading: Icon(Icons.supervised_user_circle), + title: Text('Mitglieder'), + trailing: SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ), + ) else ListTile( leading: const Icon(Icons.supervised_user_circle), @@ -201,6 +221,15 @@ class _ChatInfoState extends State { ), ), ), + ListTile( + leading: const Icon(Icons.perm_media_outlined), + title: const Text('Medien und Dokumente'), + trailing: const Icon(Icons.arrow_right), + onTap: () => TalkNavigator.pushSplitView( + context, + SharedItemsView(widget.room, overview: _sharesOverview), + ), + ), if (_isFavorite) AsyncListTile( leading: const Icon(Icons.stars_outlined), diff --git a/lib/view/pages/talk/details/shared_items_view.dart b/lib/view/pages/talk/details/shared_items_view.dart new file mode 100644 index 0000000..191f243 --- /dev/null +++ b/lib/view/pages/talk/details/shared_items_view.dart @@ -0,0 +1,680 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/errors/error_mapper.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart'; +import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../model/account_data.dart'; +import '../../../../model/endpoint_data.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'; + +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 +/// objects without a downloadable file, so they have no place in this view. +const List<_SharedCategory> _sharedCategories = [ + _SharedCategory('file', 'Dokumente', _SharedItemsLayout.list), + _SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list), + _SharedCategory('audio', 'Audio', _SharedItemsLayout.list), + _SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list), + _SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list), +]; + +class _SharedCategory { + final String objectType; + final String label; + final _SharedItemsLayout layout; + + const _SharedCategory(this.objectType, this.label, this.layout); +} + +bool _isVideoItem(GetChatResponseObject item) { + final file = item.messageParameters?['file']; + return file != null && _isVideoFile(file.name); +} + +/// One page of shared items plus the pagination cursor for the next request. +/// Produced by [buildSharedItemsPage] so the in-view loader and the overview +/// seed share the exact same paging semantics. +class SharedItemsPage { + final List items; + final int? lastKnownMessageId; + final bool hasMore; + + const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore); +} + +List _fileItems(List items) => items + .where((item) => item.messageParameters?['file']?.path != null) + .toList(); + +SharedItemsPage buildSharedItemsPage( + GetSharedItemsResponse response, + int? previousMessageId, +) { + final lastGiven = response.lastGivenMessageId; + final hasMore = + response.items.length >= _sharedItemsPageSize && + lastGiven != null && + lastGiven != previousMessageId; + return SharedItemsPage( + _fileItems(response.items), + hasMore ? lastGiven : previousMessageId, + hasMore, + ); +} + +/// Seeds a tab from the overview payload. The pagination cursor is the oldest +/// message id already shown; the per-type endpoint takes over from there via +/// 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 oldestId = rawItems.isEmpty + ? null + : rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b); + return SharedItemsPage( + _fileItems(rawItems), + hasMore ? oldestId : null, + hasMore, + ); +} + +/// WhatsApp-style overview of everything shared in a chat. One tab per Talk +/// share category, but only categories that actually contain a downloadable +/// file are shown. Each tab paginates on its own via [GetSharedItems]. +class SharedItemsView extends StatefulWidget { + final GetRoomResponseObject room; + + /// Overview fetched ahead of time (by ChatInfo) so tabs render instantly. + /// Null => this view fetches it itself on open. + final GetSharedItemsOverviewResponse? overview; + + const SharedItemsView(this.room, {this.overview, super.key}); + + /// Best-effort preload used by ChatInfo while the user is on the details + /// screen, so opening this view needs no round-trip. + static Future prefetchOverview( + String token, + ) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run(); + + @override + State createState() => _SharedItemsViewState(); +} + +class _SharedItemsViewState extends State + with SingleTickerProviderStateMixin { + GetSharedItemsOverviewResponse? _overview; + Object? _error; + + List<(String, Widget)>? _tabs; + TabController? _tabController; + + @override + void initState() { + super.initState(); + _overview = widget.overview; + if (_overview == null) { + _load(); + } else { + _prepareTabs(); + } + } + + @override + void dispose() { + _tabController?.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() => _error = null); + try { + final overview = await SharedItemsView.prefetchOverview(widget.room.token); + if (!mounted) return; + _overview = overview; + _prepareTabs(); + setState(() {}); + } catch (e) { + if (mounted) setState(() => _error = e); + } + } + + /// Builds the tabs and their controller exactly once. A scrollable [TabBar] + /// crashes with "setState during build" when an ancestor rebuild swaps the + /// [TabController] out mid-fling; owning a stable controller here avoids that. + void _prepareTabs() { + final overview = _overview; + if (overview == null) return; + + final tabs = <(String, Widget)>[]; + + // Media is one server category but two tabs: split the shared stream into + // Bilder/Videos client-side. Both tabs page the same `media` endpoint. + // Videos have no server preview here, so they render as a name list. + final mediaSeed = _seedFromOverview( + overview.itemsByType['media'] ?? const [], + ); + if (mediaSeed.items.any((item) => !_isVideoItem(item))) { + tabs.add(( + 'Bilder', + _SharedItemsTab( + token: widget.room.token, + objectType: 'media', + layout: _SharedItemsLayout.grid, + emptyLabel: 'Keine Bilder', + initialPage: mediaSeed, + itemFilter: (item) => !_isVideoItem(item), + ), + )); + } + if (mediaSeed.items.any(_isVideoItem)) { + tabs.add(( + 'Videos', + _SharedItemsTab( + token: widget.room.token, + objectType: 'media', + layout: _SharedItemsLayout.list, + emptyLabel: 'Keine Videos', + initialPage: mediaSeed, + itemFilter: _isVideoItem, + ), + )); + } + + for (final category in _sharedCategories) { + final raw = overview.itemsByType[category.objectType] ?? const []; + if (_fileItems(raw).isEmpty) continue; + tabs.add(( + category.label, + _SharedItemsTab( + token: widget.room.token, + objectType: category.objectType, + layout: category.layout, + emptyLabel: 'Keine Einträge', + initialPage: _seedFromOverview(raw), + ), + )); + } + + _tabs = tabs; + _tabController = tabs.isEmpty + ? null + : TabController(length: tabs.length, vsync: this); + } + + @override + Widget build(BuildContext context) { + if (_overview == null) { + return Scaffold( + appBar: AppBar(title: const Text('Medien und Dokumente')), + body: _error != null + ? _ErrorState(message: errorToUserMessage(_error), onRetry: _load) + : const Center(child: AppProgressIndicator.medium()), + ); + } + + final tabs = _tabs ?? const []; + if (tabs.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Medien und Dokumente')), + body: Center( + child: Text( + 'Noch nichts geteilt', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } + + final scrollable = tabs.length > 3; + return Scaffold( + appBar: AppBar( + title: const Text('Medien und Dokumente'), + bottom: TabBar( + controller: _tabController, + isScrollable: scrollable, + tabAlignment: scrollable ? TabAlignment.start : TabAlignment.fill, + tabs: [for (final tab in tabs) Tab(text: tab.$1)], + ), + ), + body: TabBarView( + controller: _tabController, + children: [for (final tab in tabs) tab.$2], + ), + ); + } +} + +enum _SharedItemsLayout { grid, list } + +class _SharedItemsTab extends StatefulWidget { + final String token; + final String objectType; + final _SharedItemsLayout layout; + final String emptyLabel; + final SharedItemsPage? initialPage; + + /// Optional client-side filter. Used to carve the combined `media` stream + /// into separate "Bilder" and "Videos" tabs; the pagination cursor still + /// tracks the full (unfiltered) stream. + final bool Function(GetChatResponseObject)? itemFilter; + + const _SharedItemsTab({ + required this.token, + required this.objectType, + required this.layout, + required this.emptyLabel, + this.initialPage, + this.itemFilter, + }); + + @override + State<_SharedItemsTab> createState() => _SharedItemsTabState(); +} + +class _SharedItemsTabState extends State<_SharedItemsTab> + with AutomaticKeepAliveClientMixin { + // When a filtered tab keeps pulling pages that contain none of its kind, stop + // auto-filling after this many empty pages so a video-sparse chat does not + // page endlessly; the user can still scroll to fetch more by hand. + static const int _maxEmptyAutoFills = 5; + + final ScrollController _scrollController = ScrollController(); + final List _items = []; + + bool _loading = false; + bool _initialLoaded = false; + bool _hasMore = true; + int? _lastKnownMessageId; + int _emptyAutoFills = 0; + Object? _error; + + @override + bool get wantKeepAlive => true; + + List _applyFilter(List items) => + widget.itemFilter == null + ? items + : items.where(widget.itemFilter!).toList(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + final prefetch = widget.initialPage; + if (prefetch != null) { + _items.addAll(_applyFilter(prefetch.items)); + _lastKnownMessageId = prefetch.lastKnownMessageId; + _hasMore = prefetch.hasMore; + _initialLoaded = true; + _scheduleFillCheck(); + } else { + _loadMore(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 400) { + _loadMore(); + } + } + + /// After layout, keep loading while the (possibly filtered) content is too + /// short to scroll — otherwise scroll-based paging could never kick in. + void _scheduleFillCheck() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _loading || !_hasMore) return; + if (_emptyAutoFills >= _maxEmptyAutoFills) return; + if (!_scrollController.hasClients) return; + if (_scrollController.position.maxScrollExtent > 0) return; + _loadMore(); + }); + } + + Future _loadMore() async { + if (_loading || !_hasMore) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final response = await GetSharedItems( + widget.token, + objectType: widget.objectType, + limit: _sharedItemsPageSize, + lastKnownMessageId: _lastKnownMessageId, + ).run(); + if (!mounted) return; + final page = buildSharedItemsPage(response, _lastKnownMessageId); + final filtered = _applyFilter(page.items); + setState(() { + _items.addAll(filtered); + _lastKnownMessageId = page.lastKnownMessageId; + _hasMore = page.hasMore; + _initialLoaded = true; + _loading = false; + _emptyAutoFills = filtered.isEmpty ? _emptyAutoFills + 1 : 0; + }); + _scheduleFillCheck(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + _initialLoaded = true; + }); + } + } + + Future _retry() { + setState(() { + _hasMore = true; + _error = null; + }); + return _loadMore(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + if (!_initialLoaded && _loading) { + return const Center(child: AppProgressIndicator.medium()); + } + + if (_items.isEmpty && _error != null) { + return _ErrorState(message: errorToUserMessage(_error), onRetry: _retry); + } + + if (_items.isEmpty) { + return Center( + child: Text( + widget.emptyLabel, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + final trailingLoader = _loading && _hasMore + ? const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: AppProgressIndicator.small()), + ), + ) + : const SliverToBoxAdapter(child: SizedBox.shrink()); + + // Stop this list's scroll notifications from bubbling into the enclosing + // TabBarView, which otherwise mis-syncs its indicator and can crash with + // "setState during build" on a fling. Our own paging uses the controller + // listener, not notifications, so nothing here depends on them bubbling. + return NotificationListener( + onNotification: (_) => true, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + if (widget.layout == _SharedItemsLayout.grid) + SliverPadding( + padding: const EdgeInsets.all(2), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _SharedItemTile( + item: _items[index], + layout: _SharedItemsLayout.grid, + ), + childCount: _items.length, + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _SharedItemTile( + item: _items[index], + layout: _SharedItemsLayout.list, + ), + childCount: _items.length, + ), + ), + trailingLoader, + ], + ), + ); + } +} + +class _SharedItemTile extends StatefulWidget { + final GetChatResponseObject item; + final _SharedItemsLayout layout; + + const _SharedItemTile({required this.item, required this.layout}); + + @override + State<_SharedItemTile> createState() => _SharedItemTileState(); +} + +class _SharedItemTileState extends State<_SharedItemTile> + with DownloadTrigger<_SharedItemTile> { + RichObjectString get _file => widget.item.messageParameters!['file']!; + + @override + String? get downloadRemotePath => _file.path; + + @override + void initState() { + super.initState(); + initDownloadTrigger(); + } + + @override + void dispose() { + disposeDownloadTrigger(); + super.dispose(); + } + + void _onTap() { + if (guardDemoAction(context)) return; + if (isDownloading) { + confirmCancelDownload(); + } else { + startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file)); + } + } + + String get _previewUrl => + 'https://${EndpointData().nextcloud().full()}' + '/index.php/core/preview?fileId=${_file.id}&x=300&y=300&a=1'; + + bool get _isVideo => _isVideoFile(_file.name); + + bool get _isDownloading => downloadJob?.status.value is DownloadInProgress; + + double? get _downloadProgress { + final status = downloadJob?.status.value; + if (status is! DownloadInProgress) return null; + return status.percent <= 0 ? null : status.percent / 100; + } + + @override + Widget build(BuildContext context) => widget.layout == _SharedItemsLayout.grid + ? _buildGrid(context) + : _buildList(context); + + Widget _buildGrid(BuildContext context) => GestureDetector( + onTap: _onTap, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: _previewUrl, + httpHeaders: AccountData().authHeaders(), + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (_) {}, + placeholder: (context, url) => + const Center(child: AppProgressIndicator.small()), + // Video thumbnails only exist when the server's preview provider + // (ffmpeg) generated one; fall back to a film icon rather than a + // broken-image glyph when it did not. + errorWidget: (context, url, error) => Center( + child: Icon( + _isVideo + ? Icons.movie_outlined + : Icons.image_not_supported_outlined, + ), + ), + ), + if (_isVideo) + const Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.play_arrow, color: Colors.white, size: 22), + ), + ), + ), + if (_isDownloading) + const ColoredBox( + color: Colors.black38, + child: Center( + child: AppProgressIndicator.small(color: Colors.white), + ), + ), + ], + ), + ), + ); + + Widget _buildList(BuildContext context) => ListTile( + leading: Icon(_iconForFile(_file.name), size: 36), + title: Text(_file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text( + '${widget.item.actorDisplayName} · ' + '${DateTime.fromMillisecondsSinceEpoch(widget.item.timestamp * 1000).formatDateShort()}', + ), + trailing: _isDownloading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + value: _downloadProgress, + ), + ) + : null, + onTap: _onTap, + ); +} + +bool _isVideoFile(String name) { + final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; + const videoExtensions = { + 'mp4', + 'mov', + 'mkv', + 'webm', + 'avi', + 'm4v', + '3gp', + 'mpeg', + 'mpg', + 'wmv', + 'flv', + }; + return videoExtensions.contains(ext); +} + +IconData _iconForFile(String name) { + if (_isVideoFile(name)) return Icons.movie_outlined; + final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; + switch (ext) { + case 'pdf': + return Icons.picture_as_pdf_outlined; + case 'doc': + case 'docx': + case 'odt': + case 'rtf': + case 'txt': + return Icons.description_outlined; + case 'xls': + case 'xlsx': + case 'ods': + case 'csv': + return Icons.table_chart_outlined; + case 'ppt': + case 'pptx': + case 'odp': + return Icons.slideshow_outlined; + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + return Icons.folder_zip_outlined; + case 'mp3': + case 'wav': + case 'm4a': + case 'ogg': + return Icons.audiotrack_outlined; + default: + return Icons.insert_drive_file_outlined; + } +} + +class _ErrorState extends StatelessWidget { + final String message; + final Future Function() onRetry; + + const _ErrorState({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('Erneut versuchen'), + ), + ], + ), + ), + ); +}