Files
Client/lib/view/pages/talk/details/shared_items_view.dart
T

681 lines
20 KiB
Dart

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<GetChatResponseObject> items;
final int? lastKnownMessageId;
final bool hasMore;
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
}
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> 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<GetChatResponseObject> 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<GetSharedItemsOverviewResponse> prefetchOverview(
String token,
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
@override
State<SharedItemsView> createState() => _SharedItemsViewState();
}
class _SharedItemsViewState extends State<SharedItemsView>
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<void> _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<GetChatResponseObject> _items = [];
bool _loading = false;
bool _initialLoaded = false;
bool _hasMore = true;
int? _lastKnownMessageId;
int _emptyAutoFills = 0;
Object? _error;
@override
bool get wantKeepAlive => true;
List<GetChatResponseObject> _applyFilter(List<GetChatResponseObject> 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<void> _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<void> _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<ScrollNotification>(
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<void> 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'),
),
],
),
),
);
}