diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index 6494e4f..a00e232 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; @@ -22,12 +23,15 @@ import 'package:paperless_mobile/features/labels/correspondent/view/widgets/corr import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; import 'package:paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; +import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:badges/badges.dart' as b; +import '../../../../core/repository/state/impl/document_type_repository_state.dart'; + class DocumentDetailsPage extends StatefulWidget { final bool allowEdit; final bool isLabelClickable; @@ -284,7 +288,7 @@ class _DocumentDetailsPageState extends State { content: document.archiveSerialNumber != null ? Text(document.archiveSerialNumber.toString()) : TextButton.icon( - icon: const Icon(Icons.archive), + icon: const Icon(Icons.archive_outlined), label: Text(S .of(context) .documentDetailsPageAssignAsnButtonLabel), @@ -369,37 +373,35 @@ class _DocumentDetailsPageState extends State { return ListView( children: [ _DetailsItem( + label: S.of(context).documentTitlePropertyLabel, content: HighlightedText( text: document.title, highlights: widget.titleAndContentQueryString?.split(" ") ?? [], style: Theme.of(context).textTheme.bodyLarge, ), - label: S.of(context).documentTitlePropertyLabel, ).paddedOnly(bottom: 16), _DetailsItem.text( - DateFormat.yMMMd().format(document.created), + DateFormat.yMMMMd().format(document.created), context: context, label: S.of(context).documentCreatedPropertyLabel, ).paddedSymmetrically(vertical: 16), Visibility( visible: document.documentType != null, child: _DetailsItem( - content: DocumentTypeWidget( - textStyle: Theme.of(context).textTheme.bodyLarge, - isClickable: widget.isLabelClickable, - documentTypeId: document.documentType, - ), label: S.of(context).documentDocumentTypePropertyLabel, + content: LabelText( + style: Theme.of(context).textTheme.bodyLarge, + id: document.documentType, + ), ).paddedSymmetrically(vertical: 16), ), Visibility( visible: document.correspondent != null, child: _DetailsItem( label: S.of(context).documentCorrespondentPropertyLabel, - content: CorrespondentWidget( - textStyle: Theme.of(context).textTheme.bodyLarge, - isClickable: widget.isLabelClickable, - correspondentId: document.correspondent, + content: LabelText( + style: Theme.of(context).textTheme.bodyLarge, + id: document.correspondent, ), ).paddedSymmetrically(vertical: 16), ), @@ -521,8 +523,11 @@ class _DocumentDetailsPageState extends State { class _DetailsItem extends StatelessWidget { final String label; final Widget content; - const _DetailsItem({Key? key, required this.label, required this.content}) - : super(key: key); + const _DetailsItem({ + Key? key, + required this.label, + required this.content, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -545,7 +550,10 @@ class _DetailsItem extends StatelessWidget { String text, { required this.label, required BuildContext context, - }) : content = Text(text, style: Theme.of(context).textTheme.bodyLarge); + }) : content = Text( + text, + style: Theme.of(context).textTheme.bodyLarge, + ); } class ColoredTabBar extends Container implements PreferredSizeWidget { diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 47257b8..83e9729 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -19,6 +19,7 @@ import 'package:paperless_mobile/features/document_upload/cubit/document_upload_ import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:paperless_mobile/features/home/view/route_description.dart'; import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart'; import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; @@ -145,6 +146,71 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + final destinations = [ + RouteDescription( + icon: const Icon(Icons.description_outlined), + selectedIcon: Icon( + Icons.description, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavDocumentsPageLabel, + ), + RouteDescription( + icon: const Icon(Icons.document_scanner_outlined), + selectedIcon: Icon( + Icons.document_scanner, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavScannerPageLabel, + ), + RouteDescription( + icon: const Icon(Icons.sell_outlined), + selectedIcon: Icon( + Icons.sell, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavLabelsPageLabel, + ), + // RouteDescription( + // icon: const Icon(Icons.inbox_outlined), + // selectedIcon: Icon( + // Icons.inbox, + // color: Theme.of(context).colorScheme.primary, + // ), + // label: S.of(context).bottomNavInboxPageLabel, + // ), + // RouteDescription( + // icon: const Icon(Icons.settings_outlined), + // selectedIcon: Icon( + // Icons.settings, + // color: Theme.of(context).colorScheme.primary, + // ), + // label: S.of(context).appDrawerSettingsLabel, + // ), + ]; + final routes = [ + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => DocumentsCubit( + context.read(), + context.read(), + ), + ), + BlocProvider( + create: (context) => SavedViewCubit( + context.read(), + ), + ), + ], + child: const DocumentsPage(), + ), + BlocProvider.value( + value: _scannerCubit, + child: const ScannerPage(), + ), + const LabelsPage(), + ]; return MultiBlocListener( listeners: [ BlocListener( @@ -172,96 +238,35 @@ class _HomePageState extends State { return Scaffold( key: rootScaffoldKey, drawer: const InfoDrawer(), - body: Row(children: [ - NavigationRail( - labelType: NavigationRailLabelType.all, - destinations: [ - NavigationRailDestination( - icon: const Icon(Icons.description_outlined), - selectedIcon: Icon( - Icons.description, - color: Theme.of(context).colorScheme.primary, - ), - label: Text(S.of(context).bottomNavDocumentsPageLabel), - ), - NavigationRailDestination( - icon: const Icon(Icons.document_scanner_outlined), - selectedIcon: Icon( - Icons.document_scanner, - color: Theme.of(context).colorScheme.primary, - ), - label: Text(S.of(context).bottomNavScannerPageLabel), - ), - NavigationRailDestination( - icon: const Icon(Icons.sell_outlined), - selectedIcon: Icon( - Icons.sell, - color: Theme.of(context).colorScheme.primary, - ), - label: Text(S.of(context).bottomNavLabelsPageLabel), - ), - ], - selectedIndex: _currentIndex, - onDestinationSelected: _onNavigationChanged, - ), - const VerticalDivider(thickness: 1, width: 1), - Expanded( - child: [ - MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => DocumentsCubit( - context.read(), - context.read(), - ), - ), - BlocProvider( - create: (context) => SavedViewCubit( - context.read(), - ), - ), - ], - child: const DocumentsPage(), + body: Row( + children: [ + NavigationRail( + labelType: NavigationRailLabelType.all, + destinations: destinations + .map((e) => e.toNavigationRailDestination()) + .toList(), + selectedIndex: _currentIndex, + onDestinationSelected: _onNavigationChanged, ), - BlocProvider.value( - value: _scannerCubit, - child: const ScannerPage(), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: routes[_currentIndex], ), - const LabelsPage(), - ][_currentIndex]), - ]), + ], + ), ); } return Scaffold( key: rootScaffoldKey, - bottomNavigationBar: BottomNavBar( + bottomNavigationBar: NavigationBar( + elevation: 4.0, selectedIndex: _currentIndex, - onNavigationChanged: _onNavigationChanged, + onDestinationSelected: _onNavigationChanged, + destinations: + destinations.map((e) => e.toNavigationDestination()).toList(), ), drawer: const InfoDrawer(), - body: [ - MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => DocumentsCubit( - context.read(), - context.read(), - ), - ), - BlocProvider( - create: (context) => SavedViewCubit( - context.read(), - ), - ), - ], - child: const DocumentsPage(), - ), - BlocProvider.value( - value: _scannerCubit, - child: const ScannerPage(), - ), - const LabelsPage(), - ][_currentIndex], + body: routes[_currentIndex], ); }, ), diff --git a/lib/features/home/view/route_description.dart b/lib/features/home/view/route_description.dart new file mode 100644 index 0000000..ee4c36a --- /dev/null +++ b/lib/features/home/view/route_description.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class RouteDescription { + final String label; + final Icon icon; + final Icon selectedIcon; + + RouteDescription({ + required this.label, + required this.icon, + required this.selectedIcon, + }); + + NavigationDestination toNavigationDestination() { + return NavigationDestination( + label: label, + icon: icon, + selectedIcon: selectedIcon, + ); + } + + NavigationRailDestination toNavigationRailDestination() { + return NavigationRailDestination( + label: Text(label), + icon: icon, + selectedIcon: selectedIcon, + ); + } + + BottomNavigationBarItem toBottomNavigationBarItem() { + return BottomNavigationBarItem( + label: label, + icon: icon, + activeIcon: selectedIcon, + ); + } +} diff --git a/lib/features/home/view/widget/bottom_navigation_bar.dart b/lib/features/home/view/widget/bottom_navigation_bar.dart index d7ed2a8..2b9a27a 100644 --- a/lib/features/home/view/widget/bottom_navigation_bar.dart +++ b/lib/features/home/view/widget/bottom_navigation_bar.dart @@ -42,6 +42,22 @@ class BottomNavBar extends StatelessWidget { ), label: S.of(context).bottomNavLabelsPageLabel, ), + NavigationDestination( + icon: const Icon(Icons.inbox_outlined), + selectedIcon: Icon( + Icons.inbox, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavInboxPageLabel, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: Icon( + Icons.settings, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).appDrawerSettingsLabel, + ), ], ); } diff --git a/lib/features/home/view/widget/info_drawer.dart b/lib/features/home/view/widget/info_drawer.dart index 7a615d9..a7745af 100644 --- a/lib/features/home/view/widget/info_drawer.dart +++ b/lib/features/home/view/widget/info_drawer.dart @@ -307,8 +307,10 @@ class _InfoDrawerState extends State { builder: (_) => LabelRepositoriesProvider( child: BlocProvider( create: (context) => InboxCubit( - context.read>(), - context.read(), + context.read(), + context.read(), + context.read(), + context.read(), )..initializeInbox(), child: const InboxPage(), ), diff --git a/lib/features/inbox/bloc/inbox_cubit.dart b/lib/features/inbox/bloc/inbox_cubit.dart index 13c3efb..493c953 100644 --- a/lib/features/inbox/bloc/inbox_cubit.dart +++ b/lib/features/inbox/bloc/inbox_cubit.dart @@ -1,16 +1,64 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; class InboxCubit extends HydratedCubit { final LabelRepository _tagsRepository; + final LabelRepository + _correspondentRepository; + final LabelRepository + _documentTypeRepository; + final PaperlessDocumentsApi _documentsApi; - InboxCubit(this._tagsRepository, this._documentsApi) - : super(const InboxState()); + final List _subscriptions = []; + + InboxCubit( + this._tagsRepository, + this._documentsApi, + this._correspondentRepository, + this._documentTypeRepository, + ) : super( + InboxState( + availableCorrespondents: + _correspondentRepository.current?.values ?? {}, + availableDocumentTypes: + _documentTypeRepository.current?.values ?? {}, + availableTags: _tagsRepository.current?.values ?? {}, + ), + ) { + _subscriptions.add( + _tagsRepository.values.listen((event) { + if (event?.hasLoaded ?? false) { + emit(state.copyWith(availableTags: event!.values)); + } + }), + ); + _subscriptions.add( + _correspondentRepository.values.listen((event) { + if (event?.hasLoaded ?? false) { + emit(state.copyWith( + availableCorrespondents: event!.values, + )); + } + }), + ); + _subscriptions.add( + _documentTypeRepository.values.listen((event) { + if (event?.hasLoaded ?? false) { + emit(state.copyWith(availableDocumentTypes: event!.values)); + } + }), + ); + } /// /// Fetches inbox tag ids and loads the inbox items (documents). @@ -135,6 +183,39 @@ class InboxCubit extends HydratedCubit { } } + Future updateDocument(DocumentModel document) async { + final updatedDocument = await _documentsApi.update(document); + emit( + state.copyWith( + inboxItems: state.inboxItems.map( + (e) => e.id == document.id ? updatedDocument : e, + ), + ), + ); + } + + Future deleteDocument(DocumentModel document) async { + int deletedId = await _documentsApi.delete(document); + emit( + state.copyWith( + inboxItems: state.inboxItems.where( + (element) => element.id != deletedId, + ), + ), + ); + } + + void loadSuggestions() { + Future.wait(state.inboxItems + .whereNot((doc) => state.suggestions.containsKey(doc.id)) + .map((e) => _documentsApi.findSuggestions(e))).then((results) { + emit(state.copyWith(suggestions: { + ...state.suggestions, + for (var r in results) r.documentId!: r + })); + }); + } + void acknowledgeHint() { emit(state.copyWith(isHintAcknowledged: true)); } @@ -148,4 +229,12 @@ class InboxCubit extends HydratedCubit { Map toJson(InboxState state) { return state.toJson(); } + + @override + Future close() { + _subscriptions.forEach((element) { + element.cancel(); + }); + return super.close(); + } } diff --git a/lib/features/inbox/bloc/state/inbox_state.dart b/lib/features/inbox/bloc/state/inbox_state.dart index 782b8de..5ea31dd 100644 --- a/lib/features/inbox/bloc/state/inbox_state.dart +++ b/lib/features/inbox/bloc/state/inbox_state.dart @@ -5,17 +5,24 @@ import 'package:json_annotation/json_annotation.dart'; part 'inbox_state.g.dart'; -@JsonSerializable() +@JsonSerializable( + ignoreUnannotated: true, +) class InboxState with EquatableMixin { - @JsonKey(ignore: true) final bool isLoaded; - @JsonKey(ignore: true) final Iterable inboxTags; - @JsonKey(ignore: true) final Iterable inboxItems; + final Map availableTags; + + final Map availableDocumentTypes; + + final Map availableCorrespondents; + + final Map suggestions; + @JsonKey() final bool isHintAcknowledged; const InboxState({ @@ -23,6 +30,10 @@ class InboxState with EquatableMixin { this.inboxTags = const [], this.inboxItems = const [], this.isHintAcknowledged = false, + this.availableTags = const {}, + this.availableDocumentTypes = const {}, + this.availableCorrespondents = const {}, + this.suggestions = const {}, }); @override @@ -31,6 +42,10 @@ class InboxState with EquatableMixin { inboxTags, inboxItems, isHintAcknowledged, + availableTags, + availableDocumentTypes, + availableCorrespondents, + suggestions, ]; InboxState copyWith({ @@ -38,12 +53,22 @@ class InboxState with EquatableMixin { Iterable? inboxTags, Iterable? inboxItems, bool? isHintAcknowledged, + Map? availableTags, + Map? availableCorrespondents, + Map? availableDocumentTypes, + Map? suggestions, }) { return InboxState( isLoaded: isLoaded ?? this.isLoaded, inboxItems: inboxItems ?? this.inboxItems, inboxTags: inboxTags ?? this.inboxTags, isHintAcknowledged: isHintAcknowledged ?? this.isHintAcknowledged, + availableCorrespondents: + availableCorrespondents ?? this.availableCorrespondents, + availableDocumentTypes: + availableDocumentTypes ?? this.availableDocumentTypes, + availableTags: availableTags ?? this.availableTags, + suggestions: suggestions ?? this.suggestions, ); } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index f4563f7..a04dfac 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -24,7 +24,6 @@ class InboxPage extends StatefulWidget { class _InboxPageState extends State { final _emptyStateRefreshIndicatorKey = GlobalKey(); - @override void initState() { super.initState(); @@ -40,9 +39,8 @@ class _InboxPageState extends State { icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(14), - child: BlocBuilder( + actions: [ + BlocBuilder( builder: (context, state) { return Align( alignment: Alignment.centerRight, @@ -59,8 +57,8 @@ class _InboxPageState extends State { ), ); }, - ).paddedSymmetrically(horizontal: 8.0), - ), + ).paddedSymmetrically(horizontal: 8) + ], ), floatingActionButton: BlocBuilder( builder: (context, state) { @@ -79,7 +77,11 @@ class _InboxPageState extends State { ); }, ), - body: BlocBuilder( + body: BlocConsumer( + listenWhen: (previous, current) => + !previous.isLoaded && current.isLoaded, + listener: (context, state) => + context.read().loadSuggestions(), builder: (context, state) { if (!state.isLoaded) { return const DocumentsListLoadingWidget(); @@ -121,7 +123,8 @@ class _InboxPageState extends State { ], ) .flattened - .toList(); + .toList() + ..add(const SliverToBoxAdapter(child: SizedBox(height: 78))); return RefreshIndicator( onRefresh: () => context.read().initializeInbox(), diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index b4f37f6..e8f9172 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -1,6 +1,5 @@ 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/repository/provider/label_repositories_provider.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; @@ -8,116 +7,35 @@ import 'package:paperless_mobile/core/repository/state/impl/document_type_reposi import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; -import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; -import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; +import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:badges/badges.dart' as b; -import 'package:paperless_mobile/extensions/string_extensions.dart'; -class InboxItem extends StatelessWidget { +class InboxItem extends StatefulWidget { static const _a4AspectRatio = 1 / 1.4142; + final void Function(DocumentModel model) onDocumentUpdated; final DocumentModel document; - const InboxItem({ super.key, required this.document, required this.onDocumentUpdated, }); + @override + State createState() => _InboxItemState(); +} + +class _InboxItemState extends State { + bool _isAsnAssignLoading = false; + @override Widget build(BuildContext context) { - return ListTile( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IntrinsicHeight( - child: Wrap( - direction: Axis.horizontal, - children: [ - Row( - children: [ - Text( - document.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge, - ) - ], - ), - Row( - children: [], - ), - ], - ), - ), - ], - ), - isThreeLine: true, - leading: AspectRatio( - aspectRatio: _a4AspectRatio, - child: DocumentPreview( - id: document.id, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - enableHero: false, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.person_outline, - size: Theme.of(context).textTheme.bodySmall?.fontSize, - ), - Flexible( - child: LabelText( - id: document.correspondent, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.description_outlined, - size: Theme.of(context).textTheme.bodySmall?.fontSize, - ), - Flexible( - child: LabelText( - id: document.documentType, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - TagsWidget( - tagIds: document.tags, - isMultiLine: false, - isClickable: false, - isSelectedPredicate: (_) => false, - showShortNames: true, - dense: true, - ), - ], - ), - trailing: document.archiveSerialNumber != null - ? Text( - document.archiveSerialNumber!.toString(), - style: Theme.of(context).textTheme.bodySmall, - ) - : null, + return GestureDetector( onTap: () async { final returnedDocument = await Navigator.push( context, @@ -125,7 +43,7 @@ class InboxItem extends StatelessWidget { builder: (context) => BlocProvider( create: (context) => DocumentDetailsCubit( context.read(), - document, + widget.document, ), child: const LabelRepositoriesProvider( child: DocumentDetailsPage( @@ -136,9 +54,232 @@ class InboxItem extends StatelessWidget { ), ); if (returnedDocument != null) { - onDocumentUpdated(returnedDocument); + widget.onDocumentUpdated(returnedDocument); } }, + child: Container( + padding: const EdgeInsets.all(4), + height: 180, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Row( + children: [ + AspectRatio( + aspectRatio: InboxItem._a4AspectRatio, + child: DocumentPreview( + id: widget.document.id, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + enableHero: false, + ), + ), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const Spacer(), + _buildCorrespondent(context), + _buildDocumentType(context), + const Spacer(), + _buildTags(), + ], + ).padded(), + ), + ], + ), + ), + SizedBox( + height: 48, + child: _buildActions(context), + ), + ], + ).padded(), + ), + ); + } + + Widget _buildActions(BuildContext context) { + final chipShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(32), + ); + final actions = [ + _buildAssignAsnAction(chipShape, context), + const SizedBox(width: 4.0), + ActionChip( + avatar: const Icon(Icons.delete_outline), + shape: chipShape, + label: const Text("Delete document"), + onPressed: () async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => + DeleteDocumentConfirmationDialog(document: widget.document), + ) ?? + false; + if (shouldDelete) { + context.read().deleteDocument(widget.document); + } + }, + ), + ]; + return BlocBuilder( + builder: (context, state) { + return ListView( + scrollDirection: Axis.horizontal, + children: [ + ...actions, + if (state.suggestions[widget.document.id] != null) ...[ + SizedBox(width: 4), + ..._buildSuggestionChips( + chipShape, + state.suggestions[widget.document.id]!, + state, + ) + ] + ], + ); + }, ); } + + ActionChip _buildAssignAsnAction( + RoundedRectangleBorder chipShape, + BuildContext context, + ) { + final hasAsn = widget.document.archiveSerialNumber != null; + return ActionChip( + avatar: _isAsnAssignLoading + ? const CircularProgressIndicator() + : hasAsn + ? null + : const Icon(Icons.archive_outlined), + shape: chipShape, + label: hasAsn + ? Text( + '${S.of(context).documentArchiveSerialNumberPropertyShortLabel} #${widget.document.archiveSerialNumber}', + ) + : const Text("Assign ASN"), + onPressed: !hasAsn + ? () { + setState(() { + _isAsnAssignLoading = true; + }); + context + .read() + .assignAsn(widget.document) + .whenComplete( + () => setState(() => _isAsnAssignLoading = false), + ); + } + : null, + ); + } + + TagsWidget _buildTags() { + return TagsWidget( + tagIds: widget.document.tags, + isMultiLine: false, + isClickable: false, + isSelectedPredicate: (_) => false, + showShortNames: true, + dense: true, + ); + } + + Row _buildDocumentType(BuildContext context) { + return _buildTextWithLeadingIcon( + Icon( + Icons.description_outlined, + size: Theme.of(context).textTheme.bodyMedium?.fontSize, + ), + LabelText( + id: widget.document.documentType, + style: Theme.of(context).textTheme.bodyMedium, + placeholder: "-", + ), + ); + } + + Row _buildCorrespondent(BuildContext context) { + return _buildTextWithLeadingIcon( + Icon( + Icons.person_outline, + size: Theme.of(context).textTheme.bodyMedium?.fontSize, + ), + LabelText( + id: widget.document.correspondent, + style: Theme.of(context).textTheme.bodyMedium, + placeholder: "-", + ), + ); + } + + Text _buildTitle() { + return Text( + widget.document.title, + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: Theme.of(context).textTheme.titleSmall, + ); + } + + Row _buildTextWithLeadingIcon(Icon icon, Widget child) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(width: 2), + Flexible( + child: child, + ), + ], + ); + } + + List _buildSuggestionChips( + OutlinedBorder chipShape, + FieldSuggestions suggestions, + InboxState state, + ) { + return [ + ...suggestions.correspondents + .map( + (e) => ActionChip( + avatar: const Icon(Icons.person_outline), + shape: chipShape, + label: Text(state.availableCorrespondents[e]?.name ?? ''), + onPressed: () { + context + .read() + .updateDocument(widget.document.copyWith( + correspondent: e, + overwriteCorrespondent: true, + )); + }, + ), + ) + .toList(), + ...suggestions.documentTypes + .map( + (e) => ActionChip( + avatar: const Icon(Icons.description_outlined), + shape: chipShape, + label: Text(state.availableDocumentTypes[e]?.name ?? ''), + onPressed: () { + context + .read() + .updateDocument(widget.document.copyWith( + documentType: e, + overwriteDocumentType: true, + )); + }, + ), + ) + .toList(), + ]; + } } diff --git a/lib/features/labels/tags/view/widgets/tags_form_field.dart b/lib/features/labels/tags/view/widgets/tags_form_field.dart index e5f6139..2bf73ff 100644 --- a/lib/features/labels/tags/view/widgets/tags_form_field.dart +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -194,7 +194,8 @@ class _TagFormFieldState extends State { Wrap( alignment: WrapAlignment.start, runAlignment: WrapAlignment.start, - spacing: 8.0, + spacing: 4.0, + runSpacing: 4.0, children: ((field.value as IdsTagsQuery).queries) .map( (query) => _buildTag( diff --git a/packages/paperless_api/lib/src/models/field_suggestions.dart b/packages/paperless_api/lib/src/models/field_suggestions.dart index 86f5ba0..0ea871b 100644 --- a/packages/paperless_api/lib/src/models/field_suggestions.dart +++ b/packages/paperless_api/lib/src/models/field_suggestions.dart @@ -4,6 +4,7 @@ part 'field_suggestions.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class FieldSuggestions { + final int? documentId; final Iterable correspondents; final Iterable tags; final Iterable documentTypes; @@ -11,6 +12,7 @@ class FieldSuggestions { final Iterable dates; const FieldSuggestions({ + this.documentId, this.correspondents = const [], this.tags = const [], this.documentTypes = const [], @@ -38,6 +40,15 @@ class FieldSuggestions { (storagePaths.isNotEmpty ? 1 : 0) + (dates.isNotEmpty ? 1 : 0); + FieldSuggestions forDocumentId(int id) => FieldSuggestions( + documentId: id, + correspondents: correspondents, + dates: dates, + documentTypes: documentTypes, + tags: tags, + storagePaths: storagePaths, + ); + factory FieldSuggestions.fromJson(Map json) => _$FieldSuggestionsFromJson(json); diff --git a/packages/paperless_api/lib/src/models/field_suggestions.g.dart b/packages/paperless_api/lib/src/models/field_suggestions.g.dart index 34d1caf..5dd427e 100644 --- a/packages/paperless_api/lib/src/models/field_suggestions.g.dart +++ b/packages/paperless_api/lib/src/models/field_suggestions.g.dart @@ -8,6 +8,7 @@ part of 'field_suggestions.dart'; FieldSuggestions _$FieldSuggestionsFromJson(Map json) => FieldSuggestions( + documentId: json['document_id'] as int?, correspondents: (json['correspondents'] as List?)?.map((e) => e as int) ?? const [], @@ -25,6 +26,7 @@ FieldSuggestions _$FieldSuggestionsFromJson(Map json) => Map _$FieldSuggestionsToJson(FieldSuggestions instance) => { + 'document_id': instance.documentId, 'correspondents': instance.correspondents.toList(), 'tags': instance.tags.toList(), 'document_types': instance.documentTypes.toList(), diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index d294cd4..b767e55 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -259,7 +259,8 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { final response = await client.get("/api/documents/${document.id}/suggestions/"); if (response.statusCode == 200) { - return FieldSuggestions.fromJson(response.data); + return FieldSuggestions.fromJson(response.data) + .forDocumentId(document.id); } throw const PaperlessServerException(ErrorCode.suggestionsQueryError); } on DioError catch (err) {