implemented shared items view for Talk chats with tabbed categories and on-scroll pagination
This commit is contained in:
@@ -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<GetSharedItemsResponse> {
|
||||
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<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -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<GetSharedItemsOverviewResponse> {
|
||||
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<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -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<String, List<GetChatResponseObject>> itemsByType;
|
||||
|
||||
GetSharedItemsOverviewResponse(this.itemsByType);
|
||||
|
||||
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final result = <String, List<GetChatResponseObject>>{};
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final entry in data.entries) {
|
||||
final value = entry.value;
|
||||
final raw = switch (value) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
result[entry.key] = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GetChatResponseObject.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return GetSharedItemsOverviewResponse(result);
|
||||
}
|
||||
}
|
||||
@@ -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<GetChatResponseObject> items;
|
||||
|
||||
GetSharedItemsResponse(this.items);
|
||||
|
||||
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final raw = switch (data) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
final items = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatInfo> {
|
||||
GetParticipantsResponse? participants;
|
||||
GetSharedItemsOverviewResponse? _sharesOverview;
|
||||
late bool _isFavorite;
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
@@ -42,6 +44,16 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
});
|
||||
},
|
||||
);
|
||||
_preloadShares();
|
||||
}
|
||||
|
||||
Future<void> _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<ChatListBloc>().refresh();
|
||||
@@ -187,7 +199,15 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
),
|
||||
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<ChatInfo> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user