import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget.dart'; import 'package:paperless_mobile/features/inbox/view/widgets/inbox_item.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; class InboxPage extends StatefulWidget { const InboxPage({super.key}); @override State createState() => _InboxPageState(); } class _InboxPageState extends State with DocumentPagingViewMixin { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle(); @override final pagingScrollController = ScrollController(); final _emptyStateRefreshIndicatorKey = GlobalKey(); final _scrollController = ScrollController(); @override Widget build(BuildContext context) { final canEditDocument = context.watch().paperlessUser.canEditDocuments; return Scaffold( drawer: const AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { if (!state.hasLoaded || state.documents.isEmpty || !canEditDocument) { return const SizedBox.shrink(); } return FloatingActionButton.extended( heroTag: "fab_inbox", label: Text(S.of(context)!.allSeen), icon: const Icon(Icons.done_all), onPressed: state.hasLoaded && state.documents.isNotEmpty ? () => _onMarkAllAsSeen( state.documents, state.inboxTags, ) : null, ); }, ), body: SafeArea( top: true, child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverOverlapAbsorber( handle: searchBarHandle, sliver: SliverSearchBar( titleText: S.of(context)!.inbox, ), ) ], body: BlocBuilder( builder: (_, state) { if (state.documents.isEmpty && state.hasLoaded) { return Center( child: InboxEmptyWidget( emptyStateRefreshIndicatorKey: _emptyStateRefreshIndicatorKey, ), ); } else if (state.isLoading) { return ListView.builder( padding: const EdgeInsets.only(top: 16, left: 16), controller: _scrollController, itemBuilder: (context, index) { return const InboxItemPlaceholder(); }, ); } else { return RefreshIndicator( onRefresh: context.read().reload, child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: HintCard( show: !state.isHintAcknowledged, hintText: S.of(context)!.swipeLeftToMarkADocumentAsSeen, onHintAcknowledged: () => context.read().acknowledgeHint(), ), ), // Build a list of slivers alternating between SliverToBoxAdapter // (group header) and a SliverList (inbox items). ..._groupByDate(state.documents) .entries .map( (entry) => [ SliverToBoxAdapter( child: Align( alignment: Alignment.centerLeft, child: ClipRRect( borderRadius: BorderRadius.circular(32.0), child: Text( entry.key, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ).padded(), ), ).paddedOnly(top: 8.0), ), SliverList( delegate: SliverChildBuilderDelegate( childCount: entry.value.length, (context, index) { if (index < entry.value.length - 1) { return Column( children: [ _buildListItem( entry.value[index], ), const Divider( indent: 16, endIndent: 16, ), ], ); } return _buildListItem( entry.value[index], ); }, ), ), ], ) .flattened .toList(), const SliverToBoxAdapter( child: SizedBox(height: 78), ), ], ), ); } }, ), ), ), ); } Widget _buildListItem(DocumentModel doc) { return Dismissible( direction: DismissDirection.endToStart, background: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Icon( Icons.done_all, color: Theme.of(context).colorScheme.primary, ).padded(), Text( S.of(context)!.markAsSeen, style: TextStyle( color: Theme.of(context).colorScheme.primary, ), ), ], ).padded(), confirmDismiss: (_) => _onItemDismissed(doc), key: ValueKey(doc.id), child: InboxItem(document: doc), ); } Future _onMarkAllAsSeen( Iterable documents, Iterable inboxTags, ) async { final isActionConfirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(S.of(context)!.markAllAsSeen), content: Text( S.of(context)!.areYouSureYouWantToMarkAllDocumentsAsSeen, ), actions: [ const DialogCancelButton(), DialogConfirmButton( label: S.of(context)!.markAsSeen, style: DialogConfirmButtonStyle.danger, ), ], ), ) ?? false; if (isActionConfirmed) { await context.read().clearInbox(); } } Future _onItemDismissed(DocumentModel doc) async { if (!context.read().paperlessUser.canEditDocuments) { showSnackBar(context, S.of(context)!.missingPermissions); return false; } try { final removedTags = await context.read().removeFromInbox(doc); showSnackBar( context, S.of(context)!.removeDocumentFromInbox, action: SnackBarActionConfig( label: S.of(context)!.undo, onPressed: () => _onUndoMarkAsSeen(doc, removedTags), ), ); return true; } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } on ServerMessageException catch (error) { showGenericError(context, error.message); } catch (error) { showErrorMessage( context, const PaperlessApiException.unknown(), ); } return false; } Future _onUndoMarkAsSeen( DocumentModel document, Iterable removedTags, ) async { try { await context .read() .undoRemoveFromInbox(document, removedTags); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } } Map> _groupByDate( Iterable documents, ) { return groupBy( documents, (doc) { if (doc.added.isToday) { return S.of(context)!.today; } if (doc.added.isYesterday) { return S.of(context)!.yesterday; } return DateFormat.yMMMMd().format(doc.added); }, ); } }