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'), ), ], ), ), ); }