From f1398e6d4c52b10e29d13c03691e0735cc29a2a4 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Sun, 30 Jul 2023 23:51:00 +0200 Subject: [PATCH 01/12] feat: Migrate to go_router --- lib/core/bloc/bloc_refresh_listenable.dart | 20 + lib/core/database/tables/global_settings.dart | 4 +- .../database/tables/local_user_account.dart | 10 +- .../database/tables/local_user_app_state.dart | 2 +- lib/core/navigation/push_routes.dart | 70 +--- lib/features/app_drawer/view/app_drawer.dart | 14 +- .../cubit/document_details_cubit.dart | 8 - .../cubit/document_details_state.dart | 1 - .../view/pages/document_details_page.dart | 66 +-- .../widgets/archive_serial_number_field.dart | 2 +- .../widgets/document_overview_widget.dart | 18 +- .../cubit/document_edit_cubit.dart | 5 + .../cubit/document_edit_state.dart | 1 + .../view/document_edit_page.dart | 58 +-- .../view/document_search_bar.dart | 18 +- .../view/document_search_page.dart | 9 +- .../view/sliver_search_bar.dart | 4 +- .../document_upload_preparation_page.dart | 27 +- .../documents/view/pages/documents_page.dart | 26 +- .../widgets/items/document_detailed_item.dart | 98 +++-- .../widgets/search/document_filter_form.dart | 15 +- .../view/impl/add_storage_path_page.dart | 6 +- .../edit_label/view/impl/add_tag_page.dart | 6 +- .../view/impl/edit_correspondent_page.dart | 6 +- .../view/impl/edit_document_type_page.dart | 6 +- .../view/impl/edit_storage_path_page.dart | 5 +- .../edit_label/view/impl/edit_tag_page.dart | 3 +- lib/features/edit_label/view/label_form.dart | 3 +- lib/features/home/view/home_page.dart | 330 --------------- lib/features/home/view/home_route.dart | 204 ---------- lib/features/home/view/home_shell_widget.dart | 208 ++++++++++ lib/features/home/view/model/api_version.dart | 4 +- .../view/scaffold_with_navigation_bar.dart | 168 ++++++++ lib/features/inbox/cubit/inbox_cubit.dart | 1 + lib/features/inbox/view/pages/inbox_page.dart | 2 +- .../inbox/view/widgets/inbox_item.dart | 14 +- .../view/widgets/fullscreen_tags_form.dart | 2 +- .../tags/view/widgets/tags_form_field.dart | 3 +- .../labels/view/pages/labels_page.dart | 331 ++++++--------- .../labels/view/widgets/label_item.dart | 3 +- lib/features/landing/view/landing_page.dart | 51 +++ .../view/linked_documents_page.dart | 9 +- .../login/cubit/authentication_cubit.dart | 18 +- .../login/cubit/authentication_state.dart | 8 +- lib/features/login/view/add_account_page.dart | 161 ++++++++ lib/features/login/view/login_page.dart | 191 +++------ .../view/saved_view_details_page.dart | 11 +- .../settings/view/manage_accounts_page.dart | 20 +- lib/features/settings/view/settings_page.dart | 10 - .../view/widgets/user_settings_builder.dart | 2 +- .../view/similar_documents_view.dart | 9 +- lib/main.dart | 383 +++++++++++------- lib/routes/document_details_route.dart | 51 --- lib/routes/navigation_keys.dart | 8 + lib/routes/routes.dart | 20 + .../typed/branches/documents_route.dart | 113 ++++++ lib/routes/typed/branches/inbox_route.dart | 17 + lib/routes/typed/branches/labels_route.dart | 84 ++++ lib/routes/typed/branches/landing_route.dart | 38 ++ lib/routes/typed/branches/scanner_route.dart | 82 ++++ .../typed/shells/provider_shell_route.dart | 72 ++++ .../typed/shells/scaffold_shell_route.dart | 29 ++ lib/routes/typed/top_level/login_route.dart | 30 ++ .../typed/top_level/settings_route.dart | 17 + .../top_level/switching_accounts_route.dart | 18 + .../top_level/verify_identity_route.dart | 19 + .../paperless_server_message_exception.dart | 4 +- .../models/labels/correspondent_model.dart | 74 ---- .../models/labels/document_type_model.dart | 59 --- .../lib/src/models/labels/label_model.dart | 284 ++++++++++++- .../src/models/labels/storage_path_model.dart | 71 ---- .../lib/src/models/labels/tag_model.dart | 91 ----- .../paperless_api/lib/src/models/models.dart | 4 - .../user_permission_extension.dart | 6 + .../labels_api/paperless_labels_api.dart | 6 +- .../labels_api/paperless_labels_api_impl.dart | 5 +- pubspec.lock | 96 +++-- pubspec.yaml | 2 + 78 files changed, 2202 insertions(+), 1752 deletions(-) create mode 100644 lib/core/bloc/bloc_refresh_listenable.dart delete mode 100644 lib/features/home/view/home_page.dart delete mode 100644 lib/features/home/view/home_route.dart create mode 100644 lib/features/home/view/home_shell_widget.dart create mode 100644 lib/features/home/view/scaffold_with_navigation_bar.dart create mode 100644 lib/features/landing/view/landing_page.dart create mode 100644 lib/features/login/view/add_account_page.dart delete mode 100644 lib/routes/document_details_route.dart create mode 100644 lib/routes/navigation_keys.dart create mode 100644 lib/routes/routes.dart create mode 100644 lib/routes/typed/branches/documents_route.dart create mode 100644 lib/routes/typed/branches/inbox_route.dart create mode 100644 lib/routes/typed/branches/labels_route.dart create mode 100644 lib/routes/typed/branches/landing_route.dart create mode 100644 lib/routes/typed/branches/scanner_route.dart create mode 100644 lib/routes/typed/shells/provider_shell_route.dart create mode 100644 lib/routes/typed/shells/scaffold_shell_route.dart create mode 100644 lib/routes/typed/top_level/login_route.dart create mode 100644 lib/routes/typed/top_level/settings_route.dart create mode 100644 lib/routes/typed/top_level/switching_accounts_route.dart create mode 100644 lib/routes/typed/top_level/verify_identity_route.dart delete mode 100644 packages/paperless_api/lib/src/models/labels/correspondent_model.dart delete mode 100644 packages/paperless_api/lib/src/models/labels/document_type_model.dart delete mode 100644 packages/paperless_api/lib/src/models/labels/storage_path_model.dart delete mode 100644 packages/paperless_api/lib/src/models/labels/tag_model.dart diff --git a/lib/core/bloc/bloc_refresh_listenable.dart b/lib/core/bloc/bloc_refresh_listenable.dart new file mode 100644 index 00000000..f7b8067c --- /dev/null +++ b/lib/core/bloc/bloc_refresh_listenable.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class GoRouterRefreshStream extends ChangeNotifier { + GoRouterRefreshStream(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen( + (dynamic _) => notifyListeners(), + ); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/lib/core/database/tables/global_settings.dart b/lib/core/database/tables/global_settings.dart index 6ee55c9b..dda69614 100644 --- a/lib/core/database/tables/global_settings.dart +++ b/lib/core/database/tables/global_settings.dart @@ -21,7 +21,7 @@ class GlobalSettings with HiveObjectMixin { bool showOnboarding; @HiveField(4) - String? currentLoggedInUser; + String? loggedInUserId; @HiveField(5) FileDownloadType defaultDownloadType; @@ -37,7 +37,7 @@ class GlobalSettings with HiveObjectMixin { this.preferredThemeMode = ThemeMode.system, this.preferredColorSchemeOption = ColorSchemeOption.classic, this.showOnboarding = true, - this.currentLoggedInUser, + this.loggedInUserId, this.defaultDownloadType = FileDownloadType.alwaysAsk, this.defaultShareType = FileDownloadType.alwaysAsk, this.enforceSinglePagePdfUpload = false, diff --git a/lib/core/database/tables/local_user_account.dart b/lib/core/database/tables/local_user_account.dart index 64e6cc4f..799ddb18 100644 --- a/lib/core/database/tables/local_user_account.dart +++ b/lib/core/database/tables/local_user_account.dart @@ -20,16 +20,16 @@ class LocalUserAccount extends HiveObject { @HiveField(7) UserModel paperlessUser; + @HiveField(8, defaultValue: 2) + int apiVersion; + LocalUserAccount({ required this.id, required this.serverUrl, required this.settings, required this.paperlessUser, + required this.apiVersion, }); - static LocalUserAccount get current => - Hive.box(HiveBoxes.localUserAccount).get( - Hive.box(HiveBoxes.globalSettings) - .getValue()! - .currentLoggedInUser)!; + bool get hasMultiUserSupport => apiVersion >= 3; } diff --git a/lib/core/database/tables/local_user_app_state.dart b/lib/core/database/tables/local_user_app_state.dart index 687eb3a5..49812e2d 100644 --- a/lib/core/database/tables/local_user_app_state.dart +++ b/lib/core/database/tables/local_user_app_state.dart @@ -43,7 +43,7 @@ class LocalUserAppState extends HiveObject { final currentLocalUserId = Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser!; + .loggedInUserId!; return Hive.box(HiveBoxes.localUserAppState) .get(currentLocalUserId)!; } diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart index 82bf09a2..77e5312f 100644 --- a/lib/core/navigation/push_routes.dart +++ b/lib/core/navigation/push_routes.dart @@ -4,11 +4,13 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -29,7 +31,6 @@ import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.da import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/routes/document_details_route.dart'; import 'package:provider/provider.dart'; // These are convenience methods for nativating to views without having to pass providers around explicitly. @@ -38,59 +39,18 @@ import 'package:provider/provider.dart'; Future pushDocumentSearchPage(BuildContext context) { final currentUser = Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser; + .loggedInUserId; final userRepo = context.read(); return Navigator.of(context).push( MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: userRepo), - ], - builder: (context, _) { - return BlocProvider( - create: (context) => DocumentSearchCubit( - context.read(), - context.read(), - Hive.box(HiveBoxes.localUserAppState) - .get(currentUser)!, - ), - child: const DocumentSearchPage(), - ); - }, - ), - ), - ); -} - -Future pushDocumentDetailsRoute( - BuildContext context, { - required DocumentModel document, - bool isLabelClickable = true, - bool allowEdit = true, - String? titleAndContentQueryString, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - if (context.read().hasMultiUserSupport) - Provider.value(value: context.read()), - ], - child: DocumentDetailsRoute( - document: document, - isLabelClickable: isLabelClickable, + builder: (_) => BlocProvider( + create: (context) => DocumentSearchCubit( + context.read(), + context.read(), + Hive.box(HiveBoxes.localUserAppState) + .get(currentUser)!, ), + child: const DocumentSearchPage(), ), ), ); @@ -106,7 +66,7 @@ Future pushSavedViewDetailsRoute( builder: (_) => MultiProvider( providers: [ Provider.value(value: apiVersion), - if (apiVersion.hasMultiUserSupport) + if (context.watch().hasMultiUserSupport) Provider.value(value: context.read()), Provider.value(value: context.read()), Provider.value(value: context.read()), @@ -147,8 +107,10 @@ Future pushAddSavedViewRoute(BuildContext context, ); } -Future pushLinkedDocumentsView(BuildContext context, - {required DocumentFilter filter}) { +Future pushLinkedDocumentsView( + BuildContext context, { + required DocumentFilter filter, +}) { return Navigator.push( context, MaterialPageRoute( @@ -161,7 +123,7 @@ Future pushLinkedDocumentsView(BuildContext context, Provider.value(value: context.read()), Provider.value(value: context.read()), Provider.value(value: context.read()), - if (context.read().hasMultiUserSupport) + if (context.watch().hasMultiUserSupport) Provider.value(value: context.read()), ], builder: (context, _) => BlocProvider( diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 48adf984..627e893b 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -9,6 +9,7 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/settings/view/settings_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -91,18 +92,7 @@ class AppDrawer extends StatelessWidget { title: Text( S.of(context)!.settings, ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value( - value: context.read()), - Provider.value(value: context.read()), - ], - child: const SettingsPage(), - ), - ), - ), + onTap: () => SettingsRoute().push(context), ), ], ), diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 3ad70070..94291eac 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -45,7 +45,6 @@ class DocumentDetailsCubit extends Cubit { ), ), ); - loadSuggestions(); loadMetaData(); } @@ -54,13 +53,6 @@ class DocumentDetailsCubit extends Cubit { _notifier.notifyDeleted(document); } - Future loadSuggestions() async { - final suggestions = await _api.findSuggestions(state.document); - if (!isClosed) { - emit(state.copyWith(suggestions: suggestions)); - } - } - Future loadMetaData() async { final metaData = await _api.getMetaData(state.document); if (!isClosed) { diff --git a/lib/features/document_details/cubit/document_details_state.dart b/lib/features/document_details/cubit/document_details_state.dart index 54a37515..6bcf0e70 100644 --- a/lib/features/document_details/cubit/document_details_state.dart +++ b/lib/features/document_details/cubit/document_details_state.dart @@ -7,7 +7,6 @@ class DocumentDetailsState with _$DocumentDetailsState { DocumentMetaData? metaData, @Default(false) bool isFullContentLoaded, String? fullContent, - FieldSuggestions? suggestions, @Default({}) Map correspondents, @Default({}) Map documentTypes, @Default({}) Map tags, 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 8732da87..79216fb0 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -14,8 +14,6 @@ import 'package:paperless_mobile/features/document_details/view/widgets/document import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; -import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; -import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.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'; @@ -24,6 +22,7 @@ import 'package:paperless_mobile/features/similar_documents/cubit/similar_docume import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class DocumentDetailsPage extends StatefulWidget { final bool isLabelClickable; @@ -46,9 +45,9 @@ class _DocumentDetailsPageState extends State { @override Widget build(BuildContext context) { - final apiVersion = context.watch(); - - final tabLength = 4 + (apiVersion.hasMultiUserSupport ? 1 : 0); + final hasMultiUserSupport = + context.watch().hasMultiUserSupport; + final tabLength = 4 + (hasMultiUserSupport ? 1 : 0); return WillPopScope( onWillPop: () async { Navigator.of(context) @@ -171,7 +170,7 @@ class _DocumentDetailsPageState extends State { ), ), ), - if (apiVersion.hasMultiUserSupport) + if (hasMultiUserSupport) Tab( child: Text( "Permissions", @@ -259,7 +258,7 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (apiVersion.hasMultiUserSupport) + if (hasMultiUserSupport) CustomScrollView( controller: _pagingScrollController, slivers: [ @@ -286,8 +285,10 @@ class _DocumentDetailsPageState extends State { } Widget _buildEditButton() { + final currentUser = context.watch(); + bool canEdit = context.watchInternetConnection && - LocalUserAccount.current.paperlessUser.canEditDocuments; + currentUser.paperlessUser.canEditDocuments; if (!canEdit) { return const SizedBox.shrink(); } @@ -302,7 +303,7 @@ class _DocumentDetailsPageState extends State { verticalOffset: 40, child: FloatingActionButton( child: const Icon(Icons.edit), - onPressed: () => _onEdit(state.document), + onPressed: () => EditDocumentRoute(state.document).push(context), ), ); }, @@ -316,9 +317,9 @@ class _DocumentDetailsPageState extends State { child: BlocBuilder( builder: (context, connectivityState) { final isConnected = connectivityState.isConnected; - - final canDelete = isConnected && - LocalUserAccount.current.paperlessUser.canDeleteDocuments; + final currentUser = context.watch(); + final canDelete = + isConnected && currentUser.paperlessUser.canDeleteDocuments; return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -360,47 +361,6 @@ class _DocumentDetailsPageState extends State { ); } - Future _onEdit(DocumentModel document) async { - { - final cubit = context.read(); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => MultiBlocProvider( - providers: [ - BlocProvider.value( - value: DocumentEditCubit( - context.read(), - context.read(), - context.read(), - document: document, - ), - ), - BlocProvider.value( - value: cubit, - ), - ], - child: BlocListener( - listenWhen: (previous, current) => - previous.document != current.document, - listener: (context, state) { - cubit.replace(state.document); - }, - child: BlocBuilder( - builder: (context, state) { - return DocumentEditPage( - suggestions: state.suggestions, - ); - }, - ), - ), - ), - maintainState: true, - ), - ); - } - } - void _onOpenFileInSystemViewer() async { final status = await context.read().openDocumentInSystemViewer(); diff --git a/lib/features/document_details/view/widgets/archive_serial_number_field.dart b/lib/features/document_details/view/widgets/archive_serial_number_field.dart index 16080100..ba2f6e26 100644 --- a/lib/features/document_details/view/widgets/archive_serial_number_field.dart +++ b/lib/features/document_details/view/widgets/archive_serial_number_field.dart @@ -47,7 +47,7 @@ class _ArchiveSerialNumberFieldState extends State { @override Widget build(BuildContext context) { final userCanEditDocument = - LocalUserAccount.current.paperlessUser.canEditDocuments; + context.watch().paperlessUser.canEditDocuments; return BlocListener( listenWhen: (previous, current) => previous.document.archiveSerialNumber != diff --git a/lib/features/document_details/view/widgets/document_overview_widget.dart b/lib/features/document_details/view/widgets/document_overview_widget.dart index 3e6804ec..b19b49af 100644 --- a/lib/features/document_details/view/widgets/document_overview_widget.dart +++ b/lib/features/document_details/view/widgets/document_overview_widget.dart @@ -1,4 +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/database/tables/local_user_account.dart'; @@ -47,7 +48,10 @@ class DocumentOverviewWidget extends StatelessWidget { label: S.of(context)!.createdAt, ).paddedOnly(bottom: itemSpacing), if (document.documentType != null && - LocalUserAccount.current.paperlessUser.canViewDocumentTypes) + context + .watch() + .paperlessUser + .canViewDocumentTypes) DetailsItem( label: S.of(context)!.documentType, content: LabelText( @@ -56,7 +60,10 @@ class DocumentOverviewWidget extends StatelessWidget { ), ).paddedOnly(bottom: itemSpacing), if (document.correspondent != null && - LocalUserAccount.current.paperlessUser.canViewCorrespondents) + context + .watch() + .paperlessUser + .canViewCorrespondents) DetailsItem( label: S.of(context)!.correspondent, content: LabelText( @@ -65,7 +72,10 @@ class DocumentOverviewWidget extends StatelessWidget { ), ).paddedOnly(bottom: itemSpacing), if (document.storagePath != null && - LocalUserAccount.current.paperlessUser.canViewStoragePaths) + context + .watch() + .paperlessUser + .canViewStoragePaths) DetailsItem( label: S.of(context)!.storagePath, content: LabelText( @@ -73,7 +83,7 @@ class DocumentOverviewWidget extends StatelessWidget { ), ).paddedOnly(bottom: itemSpacing), if (document.tags.isNotEmpty && - LocalUserAccount.current.paperlessUser.canViewTags) + context.watch().paperlessUser.canViewTags) DetailsItem( label: S.of(context)!.tags, content: Padding( diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index 966302f5..84195dd4 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -57,6 +57,11 @@ class DocumentEditCubit extends Cubit { } } + Future loadFieldSuggestions() async { + final suggestions = await _docsApi.findSuggestions(state.document); + emit(state.copyWith(suggestions: suggestions)); + } + void replace(DocumentModel document) { emit(state.copyWith(document: document)); } diff --git a/lib/features/document_edit/cubit/document_edit_state.dart b/lib/features/document_edit/cubit/document_edit_state.dart index 6504095d..0f1bb395 100644 --- a/lib/features/document_edit/cubit/document_edit_state.dart +++ b/lib/features/document_edit/cubit/document_edit_state.dart @@ -4,6 +4,7 @@ part of 'document_edit_cubit.dart'; class DocumentEditState with _$DocumentEditState { const factory DocumentEditState({ required DocumentModel document, + FieldSuggestions? suggestions, @Default({}) Map correspondents, @Default({}) Map documentTypes, @Default({}) Map storagePaths, diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index ec83909b..8cb014df 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -22,10 +22,8 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; class DocumentEditPage extends StatefulWidget { - final FieldSuggestions? suggestions; const DocumentEditPage({ Key? key, - required this.suggestions, }) : super(key: key); @override @@ -44,19 +42,12 @@ class _DocumentEditPageState extends State { final GlobalKey _formKey = GlobalKey(); bool _isSubmitLoading = false; - late final FieldSuggestions? _filteredSuggestions; - - @override - void initState() { - super.initState(); - _filteredSuggestions = widget.suggestions - ?.documentDifference(context.read().state.document); - } - @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + final filteredSuggestions = state.suggestions?.documentDifference( + context.read().state.document); return DefaultTabController( length: 2, child: Scaffold( @@ -94,8 +85,10 @@ class _DocumentEditPageState extends State { ListView( children: [ _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField(state.document.created) - .padded(), + _buildCreatedAtFormField( + state.document.created, + filteredSuggestions, + ).padded(), // Correspondent form field Column( children: [ @@ -123,15 +116,17 @@ class _DocumentEditPageState extends State { name: fkCorrespondent, prefixIcon: const Icon(Icons.person_outlined), allowSelectUnassigned: true, - canCreateNewLabel: LocalUserAccount.current - .paperlessUser.canCreateCorrespondents, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateCorrespondents, ), - if (_filteredSuggestions + if (filteredSuggestions ?.hasSuggestedCorrespondents ?? false) _buildSuggestionsSkeleton( suggestions: - _filteredSuggestions!.correspondents, + filteredSuggestions!.correspondents, itemBuilder: (context, itemData) => ActionChip( label: Text( @@ -160,8 +155,10 @@ class _DocumentEditPageState extends State { initialName: currentInput, ), ), - canCreateNewLabel: LocalUserAccount.current - .paperlessUser.canCreateDocumentTypes, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateDocumentTypes, addLabelText: S.of(context)!.addDocumentType, labelText: S.of(context)!.documentType, initialValue: @@ -175,12 +172,12 @@ class _DocumentEditPageState extends State { const Icon(Icons.description_outlined), allowSelectUnassigned: true, ), - if (_filteredSuggestions + if (filteredSuggestions ?.hasSuggestedDocumentTypes ?? false) _buildSuggestionsSkeleton( suggestions: - _filteredSuggestions!.documentTypes, + filteredSuggestions!.documentTypes, itemBuilder: (context, itemData) => ActionChip( label: Text( @@ -204,10 +201,12 @@ class _DocumentEditPageState extends State { RepositoryProvider.value( value: context.read(), child: AddStoragePathPage( - initalName: initialValue), + initialName: initialValue), ), - canCreateNewLabel: LocalUserAccount.current - .paperlessUser.canCreateStoragePaths, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateStoragePaths, addLabelText: S.of(context)!.addStoragePath, labelText: S.of(context)!.storagePath, options: state.storagePaths, @@ -232,14 +231,14 @@ class _DocumentEditPageState extends State { include: state.document.tags.toList(), ), ).padded(), - if (_filteredSuggestions?.tags + if (filteredSuggestions?.tags .toSet() .difference(state.document.tags.toSet()) .isNotEmpty ?? false) _buildSuggestionsSkeleton( suggestions: - (_filteredSuggestions?.tags.toSet() ?? {}), + (filteredSuggestions?.tags.toSet() ?? {}), itemBuilder: (context, itemData) { final tag = state.tags[itemData]!; return ActionChip( @@ -343,7 +342,8 @@ class _DocumentEditPageState extends State { ); } - Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) { + Widget _buildCreatedAtFormField( + DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -358,9 +358,9 @@ class _DocumentEditPageState extends State { format: DateFormat.yMMMMd(), initialEntryMode: DatePickerEntryMode.calendar, ), - if (_filteredSuggestions?.hasSuggestedDates ?? false) + if (filteredSuggestions?.hasSuggestedDates ?? false) _buildSuggestionsSkeleton( - suggestions: _filteredSuggestions!.dates, + suggestions: filteredSuggestions!.dates, itemBuilder: (context, itemData) => ActionChip( label: Text(DateFormat.yMMMd().format(itemData)), onPressed: () => _formKey.currentState?.fields[fkCreatedDate] diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index 7b4327b7..08de60e6 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -91,7 +91,7 @@ class _DocumentSearchBarState extends State { Provider.value(value: context.read()), Provider.value(value: context.read()), Provider.value(value: context.read()), - if (context.read().hasMultiUserSupport) + if (context.watch().hasMultiUserSupport) Provider.value(value: context.read()), ], child: Provider( @@ -99,7 +99,7 @@ class _DocumentSearchBarState extends State { context.read(), context.read(), Hive.box(HiveBoxes.localUserAppState) - .get(LocalUserAccount.current.id)!, + .get(context.watch().id)!, ), builder: (_, __) => const DocumentSearchPage(), ), @@ -112,19 +112,7 @@ class _DocumentSearchBarState extends State { IconButton _buildUserAvatar(BuildContext context) { return IconButton( padding: const EdgeInsets.all(6), - icon: GlobalSettingsBuilder( - builder: (context, settings) { - return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.localUserAccount) - .listenable(), - builder: (context, box, _) { - final account = box.get(settings.currentLoggedInUser!)!; - return UserAvatar(account: account); - }, - ); - }, - ), + icon: UserAvatar(account: context.watch()), onPressed: () { final apiVersion = context.read(); showDialog( diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index f9498914..8e43fa17 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; @@ -11,6 +12,7 @@ import 'package:paperless_mobile/features/document_search/view/remove_history_en import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class DocumentSearchPage extends StatefulWidget { const DocumentSearchPage({super.key}); @@ -218,11 +220,8 @@ class _DocumentSearchPageState extends State { hasLoaded: state.hasLoaded, enableHeroAnimation: false, onTap: (document) { - pushDocumentDetailsRoute( - context, - document: document, - isLabelClickable: false, - ); + DocumentDetailsRoute($extra: document, isLabelClickable: false) + .push(context); }, ) ], diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 67487b9a..9f8610ec 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -25,7 +25,7 @@ class SliverSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { - if (LocalUserAccount.current.paperlessUser.canViewDocuments) { + if (context.watch().paperlessUser.canViewDocuments) { return SliverAppBar( toolbarHeight: kToolbarHeight, flexibleSpace: Container( @@ -49,7 +49,7 @@ class SliverSearchBar extends StatelessWidget { Hive.box(HiveBoxes.localUserAccount) .listenable(), builder: (context, box, _) { - final account = box.get(settings.currentLoggedInUser!)!; + final account = box.get(settings.loggedInUserId!)!; return UserAvatar(account: account); }, ); diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index a1537ce5..89903876 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -198,8 +198,10 @@ class _DocumentUploadPreparationPageState ), ), // Correspondent - if (LocalUserAccount - .current.paperlessUser.canViewCorrespondents) + if (context + .watch() + .paperlessUser + .canViewCorrespondents) LabelFormField( showAnyAssignedOption: false, showNotAssignedOption: false, @@ -220,11 +222,16 @@ class _DocumentUploadPreparationPageState options: state.correspondents, prefixIcon: const Icon(Icons.person_outline), allowSelectUnassigned: true, - canCreateNewLabel: LocalUserAccount - .current.paperlessUser.canCreateCorrespondents, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateCorrespondents, ), // Document type - if (LocalUserAccount.current.paperlessUser.canViewDocumentTypes) + if (context + .watch() + .paperlessUser + .canViewDocumentTypes) LabelFormField( showAnyAssignedOption: false, showNotAssignedOption: false, @@ -245,10 +252,12 @@ class _DocumentUploadPreparationPageState options: state.documentTypes, prefixIcon: const Icon(Icons.description_outlined), allowSelectUnassigned: true, - canCreateNewLabel: LocalUserAccount - .current.paperlessUser.canCreateDocumentTypes, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateDocumentTypes, ), - if (LocalUserAccount.current.paperlessUser.canViewTags) + if (context.watch().paperlessUser.canViewTags) TagsFormField( name: DocumentModel.tagsKey, allowCreation: true, @@ -296,7 +305,7 @@ class _DocumentUploadPreparationPageState ), userId: Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser!, + .loggedInUserId!, title: title, documentType: docType, correspondent: correspondent, diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 4cdbd323..54444f05 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -23,6 +23,7 @@ import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class DocumentFilterIntent { final DocumentFilter? filter; @@ -55,7 +56,7 @@ class _DocumentsPageState extends State void initState() { super.initState(); final showSavedViews = - LocalUserAccount.current.paperlessUser.canViewSavedViews; + context.read().paperlessUser.canViewSavedViews; _tabController = TabController( length: showSavedViews ? 2 : 1, vsync: this, @@ -116,7 +117,7 @@ class _DocumentsPageState extends State return SafeArea( top: true, child: Scaffold( - drawer: const AppDrawer(), + drawer: AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { final appliedFiltersCount = state.filter.appliedFiltersCount; @@ -232,7 +233,9 @@ class _DocumentsPageState extends State controller: _tabController, tabs: [ Tab(text: S.of(context)!.documents), - if (LocalUserAccount.current.paperlessUser + if (context + .watch() + .paperlessUser .canViewSavedViews) Tab(text: S.of(context)!.views), ], @@ -276,8 +279,10 @@ class _DocumentsPageState extends State ); }, ), - if (LocalUserAccount - .current.paperlessUser.canViewSavedViews) + if (context + .watch() + .paperlessUser + .canViewSavedViews) Builder( builder: (context) { return _buildSavedViewsTab( @@ -378,7 +383,9 @@ class _DocumentsPageState extends State final allowToggleFilter = state.selection.isEmpty; return SliverAdaptiveDocumentsView( viewType: state.viewType, - onTap: _openDetails, + onTap: (document) { + DocumentDetailsRoute($extra: document).push(context); + }, onSelected: context.read().toggleDocumentSelection, hasInternetConnection: connectivityState.isConnected, @@ -488,13 +495,6 @@ class _DocumentsPageState extends State } } - void _openDetails(DocumentModel document) { - pushDocumentDetailsRoute( - context, - document: document, - ); - } - void _addTagToFilter(int tagId) { final cubit = context.read(); try { diff --git a/lib/features/documents/view/widgets/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index 3f214ae5..8fa60df7 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -2,7 +2,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:intl/intl.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; @@ -32,6 +37,12 @@ class DocumentDetailedItem extends DocumentItem { @override Widget build(BuildContext context) { + final currentUserId = Hive.box(HiveBoxes.globalSettings) + .getValue()! + .loggedInUserId; + final paperlessUser = Hive.box(HiveBoxes.localUserAccount) + .get(currentUserId)! + .paperlessUser; final size = MediaQuery.of(context).size; final insets = MediaQuery.of(context).viewInsets; final padding = MediaQuery.of(context).viewPadding; @@ -104,48 +115,51 @@ class DocumentDetailedItem extends DocumentItem { maxLines: 2, overflow: TextOverflow.ellipsis, ).paddedLTRB(8, 0, 8, 4), - Row( - children: [ - const Icon( - Icons.person_outline, - size: 16, - ).paddedOnly(right: 4.0), - CorrespondentWidget( - onSelected: onCorrespondentSelected, - textStyle: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - correspondent: context - .watch() - .state - .correspondents[document.correspondent], - ), - ], - ).paddedLTRB(8, 0, 8, 4), - Row( - children: [ - const Icon( - Icons.description_outlined, - size: 16, - ).paddedOnly(right: 4.0), - DocumentTypeWidget( - onSelected: onDocumentTypeSelected, - textStyle: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - documentType: context - .watch() - .state - .documentTypes[document.documentType], - ), - ], - ).paddedLTRB(8, 0, 8, 4), - TagsWidget( - tags: document.tags - .map((e) => context.watch().state.tags[e]!) - .toList(), - onTagSelected: onTagSelected, - ).padded(), + if (paperlessUser.canViewCorrespondents) + Row( + children: [ + const Icon( + Icons.person_outline, + size: 16, + ).paddedOnly(right: 4.0), + CorrespondentWidget( + onSelected: onCorrespondentSelected, + textStyle: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + correspondent: context + .watch() + .state + .correspondents[document.correspondent], + ), + ], + ).paddedLTRB(8, 0, 8, 4), + if (paperlessUser.canViewDocumentTypes) + Row( + children: [ + const Icon( + Icons.description_outlined, + size: 16, + ).paddedOnly(right: 4.0), + DocumentTypeWidget( + onSelected: onDocumentTypeSelected, + textStyle: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + documentType: context + .watch() + .state + .documentTypes[document.documentType], + ), + ], + ).paddedLTRB(8, 0, 8, 4), + if (paperlessUser.canViewTags) + TagsWidget( + tags: document.tags + .map((e) => context.watch().state.tags[e]!) + .toList(), + onTagSelected: onTagSelected, + ).padded(), if (highlights != null) Html( data: '

${highlights!}

', diff --git a/lib/features/documents/view/widgets/search/document_filter_form.dart b/lib/features/documents/view/widgets/search/document_filter_form.dart index 41a9ca09..29cb7a01 100644 --- a/lib/features/documents/view/widgets/search/document_filter_form.dart +++ b/lib/features/documents/view/widgets/search/document_filter_form.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; @@ -160,8 +161,10 @@ class _DocumentFilterFormState extends State { initialValue: widget.initialFilter.documentType, prefixIcon: const Icon(Icons.description_outlined), allowSelectUnassigned: false, - canCreateNewLabel: - LocalUserAccount.current.paperlessUser.canCreateDocumentTypes, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateDocumentTypes, ); } @@ -173,8 +176,10 @@ class _DocumentFilterFormState extends State { initialValue: widget.initialFilter.correspondent, prefixIcon: const Icon(Icons.person_outline), allowSelectUnassigned: false, - canCreateNewLabel: - LocalUserAccount.current.paperlessUser.canCreateCorrespondents, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateCorrespondents, ); } @@ -187,7 +192,7 @@ class _DocumentFilterFormState extends State { prefixIcon: const Icon(Icons.folder_outlined), allowSelectUnassigned: false, canCreateNewLabel: - LocalUserAccount.current.paperlessUser.canCreateStoragePaths, + context.watch().paperlessUser.canCreateStoragePaths, ); } diff --git a/lib/features/edit_label/view/impl/add_storage_path_page.dart b/lib/features/edit_label/view/impl/add_storage_path_page.dart index 3e2d311a..b033a72c 100644 --- a/lib/features/edit_label/view/impl/add_storage_path_page.dart +++ b/lib/features/edit_label/view/impl/add_storage_path_page.dart @@ -7,8 +7,8 @@ import 'package:paperless_mobile/features/labels/storage_path/view/widgets/stora import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AddStoragePathPage extends StatelessWidget { - final String? initalName; - const AddStoragePathPage({Key? key, this.initalName}) : super(key: key); + final String? initialName; + const AddStoragePathPage({Key? key, this.initialName}) : super(key: key); @override Widget build(BuildContext context) { @@ -19,7 +19,7 @@ class AddStoragePathPage extends StatelessWidget { child: AddLabelPage( pageTitle: Text(S.of(context)!.addStoragePath), fromJsonT: StoragePath.fromJson, - initialName: initalName, + initialName: initialName, onSubmit: (context, label) => context.read().addStoragePath(label), additionalFields: const [ diff --git a/lib/features/edit_label/view/impl/add_tag_page.dart b/lib/features/edit_label/view/impl/add_tag_page.dart index 3a135012..70ae6382 100644 --- a/lib/features/edit_label/view/impl/add_tag_page.dart +++ b/lib/features/edit_label/view/impl/add_tag_page.dart @@ -10,8 +10,8 @@ import 'package:paperless_mobile/features/edit_label/view/add_label_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AddTagPage extends StatelessWidget { - final String? initialValue; - const AddTagPage({Key? key, this.initialValue}) : super(key: key); + final String? initialName; + const AddTagPage({Key? key, this.initialName}) : super(key: key); @override Widget build(BuildContext context) { @@ -22,7 +22,7 @@ class AddTagPage extends StatelessWidget { child: AddLabelPage( pageTitle: Text(S.of(context)!.addTag), fromJsonT: Tag.fromJson, - initialName: initialValue, + initialName: initialName, onSubmit: (context, label) => context.read().addTag(label), additionalFields: [ diff --git a/lib/features/edit_label/view/impl/edit_correspondent_page.dart b/lib/features/edit_label/view/impl/edit_correspondent_page.dart index 1dce099e..c358cd86 100644 --- a/lib/features/edit_label/view/impl/edit_correspondent_page.dart +++ b/lib/features/edit_label/view/impl/edit_correspondent_page.dart @@ -24,8 +24,10 @@ class EditCorrespondentPage extends StatelessWidget { context.read().replaceCorrespondent(label), onDelete: (context, label) => context.read().removeCorrespondent(label), - canDelete: - LocalUserAccount.current.paperlessUser.canDeleteCorrespondents, + canDelete: context + .watch() + .paperlessUser + .canDeleteCorrespondents, ); }), ); diff --git a/lib/features/edit_label/view/impl/edit_document_type_page.dart b/lib/features/edit_label/view/impl/edit_document_type_page.dart index afd2f7ff..824e0e73 100644 --- a/lib/features/edit_label/view/impl/edit_document_type_page.dart +++ b/lib/features/edit_label/view/impl/edit_document_type_page.dart @@ -22,8 +22,10 @@ class EditDocumentTypePage extends StatelessWidget { context.read().replaceDocumentType(label), onDelete: (context, label) => context.read().removeDocumentType(label), - canDelete: - LocalUserAccount.current.paperlessUser.canDeleteDocumentTypes, + canDelete: context + .watch() + .paperlessUser + .canDeleteDocumentTypes, ), ); } diff --git a/lib/features/edit_label/view/impl/edit_storage_path_page.dart b/lib/features/edit_label/view/impl/edit_storage_path_page.dart index 3a56b554..91d512c9 100644 --- a/lib/features/edit_label/view/impl/edit_storage_path_page.dart +++ b/lib/features/edit_label/view/impl/edit_storage_path_page.dart @@ -23,7 +23,10 @@ class EditStoragePathPage extends StatelessWidget { context.read().replaceStoragePath(label), onDelete: (context, label) => context.read().removeStoragePath(label), - canDelete: LocalUserAccount.current.paperlessUser.canDeleteStoragePaths, + canDelete: context + .watch() + .paperlessUser + .canDeleteStoragePaths, additionalFields: [ StoragePathAutofillFormBuilderField( name: StoragePath.pathKey, diff --git a/lib/features/edit_label/view/impl/edit_tag_page.dart b/lib/features/edit_label/view/impl/edit_tag_page.dart index fbd62af8..48fa3bd6 100644 --- a/lib/features/edit_label/view/impl/edit_tag_page.dart +++ b/lib/features/edit_label/view/impl/edit_tag_page.dart @@ -26,7 +26,8 @@ class EditTagPage extends StatelessWidget { context.read().replaceTag(label), onDelete: (context, label) => context.read().removeTag(label), - canDelete: LocalUserAccount.current.paperlessUser.canDeleteTags, + canDelete: + context.watch().paperlessUser.canDeleteTags, additionalFields: [ FormBuilderColorPickerField( initialValue: tag.color, diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index 0a266003..d0ce3cec 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; @@ -68,7 +69,7 @@ class _LabelFormState extends State> { Widget build(BuildContext context) { List selectableMatchingAlgorithmValues = getSelectableMatchingAlgorithmValues( - context.watch().hasMultiUserSupport, + context.watch().hasMultiUserSupport, ); return Scaffold( resizeToAvoidBottomInset: false, diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart deleted file mode 100644 index ae05192a..00000000 --- a/lib/features/home/view/home_page.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hive/hive.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/global_settings.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; -import 'package:paperless_mobile/core/service/file_description.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; -import 'package:paperless_mobile/features/document_scan/view/scanner_page.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/inbox/cubit/inbox_cubit.dart'; -import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; -import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; -import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; -import 'package:paperless_mobile/features/sharing/share_intent_queue.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:responsive_builder/responsive_builder.dart'; - -/// Wrapper around all functionality for a logged in user. -/// Performs initialization logic. -class HomePage extends StatefulWidget { - final int paperlessApiVersion; - const HomePage({Key? key, required this.paperlessApiVersion}) - : super(key: key); - - @override - _HomePageState createState() => _HomePageState(); -} - -class _HomePageState extends State with WidgetsBindingObserver { - int _currentIndex = 0; - Timer? _inboxTimer; - late final StreamSubscription _shareMediaSubscription; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - - final currentUser = Hive.box(HiveBoxes.globalSettings) - .getValue()! - .currentLoggedInUser!; - // For sharing files coming from outside the app while the app is still opened - _shareMediaSubscription = ReceiveSharingIntent.getMediaStream().listen( - (files) => - ShareIntentQueue.instance.addAll(files, userId: currentUser)); - // For sharing files coming from outside the app while the app is closed - ReceiveSharingIntent.getInitialMedia().then((files) => - ShareIntentQueue.instance.addAll(files, userId: currentUser)); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _listenForReceivedFiles(); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - - void _listenToInboxChanges() { - if (LocalUserAccount.current.paperlessUser.canViewTags) { - _inboxTimer = Timer.periodic(const Duration(seconds: 60), (timer) { - if (!mounted) { - timer.cancel(); - } else { - context.read().refreshItemsInInboxCount(); - } - }); - } - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - log('App is now in foreground'); - context.read().reload(); - log("Reloaded device connectivity state"); - if (!(_inboxTimer?.isActive ?? true)) { - _listenToInboxChanges(); - } - break; - case AppLifecycleState.inactive: - case AppLifecycleState.paused: - case AppLifecycleState.detached: - default: - log('App is now in background'); - _inboxTimer?.cancel(); - break; - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _inboxTimer?.cancel(); - _shareMediaSubscription.cancel(); - super.dispose(); - } - - void _listenForReceivedFiles() async { - final currentUser = Hive.box(HiveBoxes.globalSettings) - .getValue()! - .currentLoggedInUser!; - if (ShareIntentQueue.instance.userHasUnhandlesFiles(currentUser)) { - await _handleReceivedFile(ShareIntentQueue.instance.pop(currentUser)!); - } - ShareIntentQueue.instance.addListener(() async { - final queue = ShareIntentQueue.instance; - while (queue.userHasUnhandlesFiles(currentUser)) { - final file = queue.pop(currentUser)!; - await _handleReceivedFile(file); - } - }); - } - - bool _isFileTypeSupported(SharedMediaFile file) { - return supportedFileExtensions.contains( - file.path.split('.').last.toLowerCase(), - ); - } - - Future _handleReceivedFile(final SharedMediaFile file) async { - SharedMediaFile mediaFile; - if (Platform.isIOS) { - // Workaround for file not found on iOS: https://stackoverflow.com/a/72813212 - mediaFile = SharedMediaFile( - file.path.replaceAll('file://', ''), - file.thumbnail, - file.duration, - file.type, - ); - } else { - mediaFile = file; - } - debugPrint("Consuming media file: ${mediaFile.path}"); - if (!_isFileTypeSupported(mediaFile)) { - Fluttertoast.showToast( - msg: translateError(context, ErrorCode.unsupportedFileFormat), - ); - if (Platform.isAndroid) { - // As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines. - await SystemNavigator.pop(); - } - return; - } - - if (!LocalUserAccount.current.paperlessUser.canCreateDocuments) { - Fluttertoast.showToast( - msg: "You do not have the permissions to upload documents.", - ); - return; - } - final fileDescription = FileDescription.fromPath(mediaFile.path); - if (await File(mediaFile.path).exists()) { - final bytes = await File(mediaFile.path).readAsBytes(); - final result = await pushDocumentUploadPreparationPage( - context, - bytes: bytes, - filename: fileDescription.filename, - title: fileDescription.filename, - fileExtension: fileDescription.extension, - ); - if (result?.success ?? false) { - await Fluttertoast.showToast( - msg: S.of(context)!.documentSuccessfullyUploadedProcessing, - ); - SystemNavigator.pop(); - } - } else { - Fluttertoast.showToast( - msg: S.of(context)!.couldNotAccessReceivedFile, - toastLength: Toast.LENGTH_LONG, - ); - } - } - - @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)!.documents, - ), - if (LocalUserAccount.current.paperlessUser.canCreateDocuments) - RouteDescription( - icon: const Icon(Icons.document_scanner_outlined), - selectedIcon: Icon( - Icons.document_scanner, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context)!.scanner, - ), - RouteDescription( - icon: const Icon(Icons.sell_outlined), - selectedIcon: Icon( - Icons.sell, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context)!.labels, - ), - if (LocalUserAccount.current.paperlessUser.canViewTags) - RouteDescription( - icon: const Icon(Icons.inbox_outlined), - selectedIcon: Icon( - Icons.inbox, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context)!.inbox, - badgeBuilder: (icon) => BlocBuilder( - builder: (context, state) { - return Badge.count( - isLabelVisible: state.itemsInInboxCount > 0, - count: state.itemsInInboxCount, - child: icon, - ); - }, - ), - ), - ]; - final routes = [ - const DocumentsPage(), - if (LocalUserAccount.current.paperlessUser.canCreateDocuments) - const ScannerPage(), - const LabelsPage(), - if (LocalUserAccount.current.paperlessUser.canViewTags) const InboxPage(), - ]; - return MultiBlocListener( - listeners: [ - BlocListener( - // If app was started offline, load data once it comes back online. - listenWhen: (previous, current) => - previous != ConnectivityState.connected && - current == ConnectivityState.connected, - listener: (context, state) async { - try { - debugPrint( - "[HomePage] BlocListener#listener: " - "Loading saved views and labels...", - ); - await Future.wait([ - context.read().initialize(), - context.read().initialize(), - ]); - debugPrint("[HomePage] BlocListener#listener: " - "Saved views and labels successfully loaded."); - } catch (error, stackTrace) { - debugPrint( - '[HomePage] BlocListener.listener: ' - 'An error occurred while loading saved views and labels.\n' - '${error.toString()}', - ); - debugPrintStack(stackTrace: stackTrace); - } - }, - ), - BlocListener( - listener: (context, state) { - if (state.task != null) { - // Handle local notifications on task change (only when app is running for now). - context - .read() - .notifyTaskChanged(state.task!); - } - }, - ), - ], - child: ResponsiveBuilder( - builder: (context, sizingInformation) { - if (!sizingInformation.isMobile) { - return Scaffold( - body: Row( - children: [ - NavigationRail( - labelType: NavigationRailLabelType.all, - destinations: destinations - .map((e) => e.toNavigationRailDestination()) - .toList(), - selectedIndex: _currentIndex, - onDestinationSelected: _onNavigationChanged, - ), - const VerticalDivider(thickness: 1, width: 1), - Expanded( - child: routes[_currentIndex], - ), - ], - ), - ); - } - return Scaffold( - bottomNavigationBar: NavigationBar( - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - elevation: 4.0, - selectedIndex: _currentIndex, - onDestinationSelected: _onNavigationChanged, - destinations: - destinations.map((e) => e.toNavigationDestination()).toList(), - ), - body: routes[_currentIndex], - ); - }, - ), - ); - } - - void _onNavigationChanged(index) { - if (_currentIndex != index) { - setState(() => _currentIndex = index); - } - } -} diff --git a/lib/features/home/view/home_route.dart b/lib/features/home/view/home_route.dart deleted file mode 100644 index 7b3b0829..00000000 --- a/lib/features/home/view/home_route.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; -import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; -import 'package:paperless_mobile/core/repository/user_repository.dart'; -import 'package:paperless_mobile/core/security/session_manager.dart'; -import 'package:paperless_mobile/core/service/dio_file_service.dart'; -import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; -import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; -import 'package:paperless_mobile/features/home/view/home_page.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; -import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; -import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; -import 'package:provider/provider.dart'; - -class HomeRoute extends StatelessWidget { - /// The id of the currently authenticated user (e.g. demo@paperless.example.com) - final String localUserId; - - /// The Paperless API version of the currently connected instance - final int paperlessApiVersion; - - // A factory providing the API implementations given an API version - final PaperlessApiFactory paperlessProviderFactory; - - const HomeRoute({ - super.key, - required this.paperlessApiVersion, - required this.paperlessProviderFactory, - required this.localUserId, - }); - - @override - Widget build(BuildContext context) { - return GlobalSettingsBuilder( - builder: (context, settings) { - final currentLocalUserId = settings.currentLoggedInUser; - if (currentLocalUserId == null) { - // This is the case when the current user logs out of the app. - return SizedBox.shrink(); - } - final currentUser = - Hive.box(HiveBoxes.localUserAccount) - .get(currentLocalUserId)!; - final apiVersion = ApiVersion(paperlessApiVersion); - return MultiProvider( - providers: [ - Provider.value(value: apiVersion), - Provider( - create: (context) => CacheManager( - Config( - // Isolated cache per user. - localUserId, - fileService: - DioFileService(context.read().client), - ), - ), - ), - ProxyProvider( - update: (context, value, previous) => - paperlessProviderFactory.createDocumentsApi( - value.client, - apiVersion: paperlessApiVersion, - ), - ), - ProxyProvider( - update: (context, value, previous) => - paperlessProviderFactory.createLabelsApi( - value.client, - apiVersion: paperlessApiVersion, - ), - ), - ProxyProvider( - update: (context, value, previous) => - paperlessProviderFactory.createSavedViewsApi( - value.client, - apiVersion: paperlessApiVersion, - ), - ), - ProxyProvider( - update: (context, value, previous) => - paperlessProviderFactory.createServerStatsApi( - value.client, - apiVersion: paperlessApiVersion, - ), - ), - ProxyProvider( - update: (context, value, previous) => - paperlessProviderFactory.createTasksApi( - value.client, - apiVersion: paperlessApiVersion, - ), - ), - if (apiVersion.hasMultiUserSupport) - ProxyProvider( - update: (context, value, previous) => PaperlessUserApiV3Impl( - value.client, - ), - ), - ], - builder: (context, child) { - return MultiProvider( - providers: [ - ProxyProvider( - update: (context, value, previous) { - final repo = LabelRepository(value); - if (currentUser.paperlessUser.canViewCorrespondents) { - repo.findAllCorrespondents(); - } - if (currentUser.paperlessUser.canViewDocumentTypes) { - repo.findAllDocumentTypes(); - } - if (currentUser.paperlessUser.canViewTags) { - repo.findAllTags(); - } - if (currentUser.paperlessUser.canViewStoragePaths) { - repo.findAllStoragePaths(); - } - return repo; - }, - ), - ProxyProvider( - update: (context, value, previous) { - final repo = SavedViewRepository(value); - if (currentUser.paperlessUser.canViewSavedViews) { - repo.initialize(); - } - return repo; - }, - ), - ], - builder: (context, child) { - return MultiProvider( - providers: [ - ProxyProvider3< - PaperlessDocumentsApi, - DocumentChangedNotifier, - LabelRepository, - DocumentsCubit>( - update: - (context, docApi, notifier, labelRepo, previous) => - DocumentsCubit( - docApi, - notifier, - labelRepo, - Hive.box(HiveBoxes.localUserAppState) - .get(currentLocalUserId)!, - )..initialize(), - ), - Provider( - create: (context) => - DocumentScannerCubit(context.read())), - ProxyProvider4< - PaperlessDocumentsApi, - PaperlessServerStatsApi, - LabelRepository, - DocumentChangedNotifier, - InboxCubit>( - update: (context, docApi, statsApi, labelRepo, notifier, - previous) => - InboxCubit( - docApi, - statsApi, - labelRepo, - notifier, - )..initialize(), - ), - ProxyProvider( - update: (context, savedViewRepo, previous) => - SavedViewCubit(savedViewRepo), - ), - ProxyProvider( - update: (context, value, previous) => LabelCubit(value), - ), - ProxyProvider( - update: (context, value, previous) => - TaskStatusCubit(value), - ), - if (paperlessApiVersion >= 3) - ProxyProvider( - update: (context, value, previous) => - UserRepository(value)..initialize(), - ), - ], - child: HomePage(paperlessApiVersion: paperlessApiVersion), - ); - }, - ); - }, - ); - }, - ); - } -} diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart new file mode 100644 index 00000000..69c8a95b --- /dev/null +++ b/lib/features/home/view/home_shell_widget.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; +import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; +import 'package:paperless_mobile/core/repository/user_repository.dart'; +import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/service/dio_file_service.dart'; +import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; +import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; +import 'package:paperless_mobile/features/home/view/model/api_version.dart'; +import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; +import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; +import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:provider/provider.dart'; + +class HomeShellWidget extends StatelessWidget { + /// The id of the currently authenticated user (e.g. demo@paperless.example.com) + final String localUserId; + + /// The Paperless API version of the currently connected instance + final int paperlessApiVersion; + + // A factory providing the API implementations given an API version + final PaperlessApiFactory paperlessProviderFactory; + + final Widget child; + + const HomeShellWidget({ + super.key, + required this.paperlessApiVersion, + required this.paperlessProviderFactory, + required this.localUserId, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GlobalSettingsBuilder( + builder: (context, settings) { + final currentUserId = settings.loggedInUserId; + if (currentUserId == null) { + // This is the case when the current user logs out of the app. + return SizedBox.shrink(); + } + final apiVersion = ApiVersion(paperlessApiVersion); + return ValueListenableBuilder( + valueListenable: + Hive.box(HiveBoxes.localUserAccount) + .listenable(keys: [currentUserId]), + builder: (context, box, _) { + final currentLocalUser = box.get(currentUserId)!; + return MultiProvider( + providers: [ + Provider.value(value: currentLocalUser), + Provider.value(value: apiVersion), + Provider( + create: (context) => CacheManager( + Config( + // Isolated cache per user. + localUserId, + fileService: + DioFileService(context.read().client), + ), + ), + ), + Provider( + create: (context) => + paperlessProviderFactory.createDocumentsApi( + context.read().client, + apiVersion: paperlessApiVersion, + ), + ), + Provider( + create: (context) => paperlessProviderFactory.createLabelsApi( + context.read().client, + apiVersion: paperlessApiVersion, + ), + ), + Provider( + create: (context) => + paperlessProviderFactory.createSavedViewsApi( + context.read().client, + apiVersion: paperlessApiVersion, + ), + ), + Provider( + create: (context) => + paperlessProviderFactory.createServerStatsApi( + context.read().client, + apiVersion: paperlessApiVersion, + ), + ), + Provider( + create: (context) => paperlessProviderFactory.createTasksApi( + context.read().client, + apiVersion: paperlessApiVersion, + ), + ), + if (currentLocalUser.hasMultiUserSupport) + Provider( + create: (context) => PaperlessUserApiV3Impl( + context.read().client, + ), + ), + ], + builder: (context, _) { + return MultiProvider( + providers: [ + Provider( + create: (context) { + final repo = LabelRepository(context.read()); + if (currentLocalUser + .paperlessUser.canViewCorrespondents) { + repo.findAllCorrespondents(); + } + if (currentLocalUser + .paperlessUser.canViewDocumentTypes) { + repo.findAllDocumentTypes(); + } + if (currentLocalUser.paperlessUser.canViewTags) { + repo.findAllTags(); + } + if (currentLocalUser + .paperlessUser.canViewStoragePaths) { + repo.findAllStoragePaths(); + } + return repo; + }, + ), + Provider( + create: (context) { + final repo = SavedViewRepository(context.read()); + if (currentLocalUser.paperlessUser.canViewSavedViews) { + repo.initialize(); + } + return repo; + }, + ), + ], + builder: (context, _) { + return MultiProvider( + providers: [ + Provider( + create: (context) => DocumentsCubit( + context.read(), + context.read(), + context.read(), + Hive.box( + HiveBoxes.localUserAppState) + .get(currentUserId)!, + )..initialize(), + ), + Provider( + create: (context) => + DocumentScannerCubit(context.read()), + ), + if (currentLocalUser.paperlessUser.canViewDocuments && + currentLocalUser.paperlessUser.canViewTags) + Provider( + create: (context) => InboxCubit( + context.read(), + context.read(), + context.read(), + context.read(), + ).initialize(), + ), + Provider( + create: (context) => SavedViewCubit( + context.read(), + ), + ), + Provider( + create: (context) => LabelCubit( + context.read(), + ), + ), + Provider( + create: (context) => TaskStatusCubit( + context.read(), + ), + ), + if (currentLocalUser.hasMultiUserSupport) + Provider( + create: (context) => UserRepository( + context.read(), + )..initialize(), + ), + ], + child: child, + ); + }, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/home/view/model/api_version.dart b/lib/features/home/view/model/api_version.dart index a7cabd19..1a70fcd6 100644 --- a/lib/features/home/view/model/api_version.dart +++ b/lib/features/home/view/model/api_version.dart @@ -1,7 +1,7 @@ class ApiVersion { final int version; - ApiVersion(this.version); + const ApiVersion(this.version); - bool get hasMultiUserSupport => version >= 3; + } diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart new file mode 100644 index 00000000..518b8d3e --- /dev/null +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -0,0 +1,168 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; +import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +const _landingPage = 0; +const _documentsIndex = 1; +const _scannerIndex = 2; +const _labelsIndex = 3; +const _inboxIndex = 4; + +class ScaffoldWithNavigationBar extends StatefulWidget { + final UserModel authenticatedUser; + final StatefulNavigationShell navigationShell; + const ScaffoldWithNavigationBar({ + super.key, + required this.authenticatedUser, + required this.navigationShell, + }); + + @override + State createState() => + ScaffoldWithNavigationBarState(); +} + +class ScaffoldWithNavigationBarState extends State { + @override + Widget build(BuildContext context) { + final disabledColor = Theme.of(context).disabledColor; + final primaryColor = Theme.of(context).colorScheme.primary; + return Scaffold( + drawer: const AppDrawer(), + bottomNavigationBar: NavigationBar( + selectedIndex: widget.navigationShell.currentIndex, + onDestinationSelected: (index) { + switch (index) { + case _landingPage: + widget.navigationShell.goBranch(index); + break; + case _documentsIndex: + if (widget.authenticatedUser.canViewDocuments) { + widget.navigationShell.goBranch(index); + } else { + showSnackBar( + context, "You do not have permission to access this page."); + } + break; + case _scannerIndex: + if (widget.authenticatedUser.canCreateDocuments) { + widget.navigationShell.goBranch(index); + } else { + showSnackBar( + context, "You do not have permission to access this page."); + } + break; + case _labelsIndex: + if (widget.authenticatedUser.canViewAnyLabel) { + widget.navigationShell.goBranch(index); + } else { + showSnackBar( + context, "You do not have permission to access this page."); + } + break; + case _inboxIndex: + if (widget.authenticatedUser.canViewDocuments && + widget.authenticatedUser.canViewTags) { + widget.navigationShell.goBranch(index); + } else { + showSnackBar( + context, "You do not have permission to access this page."); + } + break; + default: + break; + } + }, + destinations: [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon( + Icons.home, + color: primaryColor, + ), + label: "Home", //TODO: INTL + ), + NavigationDestination( + icon: Icon( + Icons.description_outlined, + color: !widget.authenticatedUser.canViewDocuments + ? disabledColor + : null, + ), + selectedIcon: Icon( + Icons.description, + color: primaryColor, + ), + label: S.of(context)!.documents, + ), + NavigationDestination( + icon: Icon( + Icons.document_scanner_outlined, + color: !widget.authenticatedUser.canCreateDocuments + ? disabledColor + : null, + ), + selectedIcon: Icon( + Icons.document_scanner, + color: primaryColor, + ), + label: S.of(context)!.scanner, + ), + NavigationDestination( + icon: Icon( + Icons.sell_outlined, + color: !widget.authenticatedUser.canViewAnyLabel + ? disabledColor + : null, + ), + selectedIcon: Icon( + Icons.sell, + color: primaryColor, + ), + label: S.of(context)!.labels, + ), + NavigationDestination( + icon: Builder(builder: (context) { + if (!(widget.authenticatedUser.canViewDocuments && + widget.authenticatedUser.canViewTags)) { + return Icon( + Icons.close, + color: disabledColor, + ); + } + return BlocBuilder( + builder: (context, state) { + return Badge.count( + isLabelVisible: state.itemsInInboxCount > 0, + count: state.itemsInInboxCount, + child: const Icon(Icons.inbox_outlined), + ); + }, + ); + }), + selectedIcon: BlocBuilder( + builder: (context, state) { + return Badge.count( + isLabelVisible: state.itemsInInboxCount > 0, + count: state.itemsInInboxCount, + child: Icon( + Icons.inbox, + color: primaryColor, + ), + ); + }, + ), + label: S.of(context)!.inbox, + ), + ], + ), + body: widget.navigationShell, + ); + } +} diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 3356fd53..ffee09c4 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -61,6 +61,7 @@ class InboxCubit extends HydratedCubit Future initialize() async { await refreshItemsInInboxCount(false); await loadInbox(); + super.initialize(); } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 32b79344..5ee407d6 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -39,7 +39,7 @@ class _InboxPageState extends State @override Widget build(BuildContext context) { final canEditDocument = - LocalUserAccount.current.paperlessUser.canEditDocuments; + context.watch().paperlessUser.canEditDocuments; return Scaffold( drawer: const AppDrawer(), floatingActionButton: BlocBuilder( diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index c0da6050..656088f7 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; @@ -15,6 +16,7 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.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/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class InboxItemPlaceholder extends StatelessWidget { const InboxItemPlaceholder({super.key}); @@ -150,11 +152,10 @@ class _InboxItemState extends State { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { - pushDocumentDetailsRoute( - context, - document: widget.document, + DocumentDetailsRoute( + $extra: widget.document, isLabelClickable: false, - ); + ).push(context); }, child: SizedBox( height: 200, @@ -238,8 +239,9 @@ class _InboxItemState extends State { } Widget _buildActions(BuildContext context) { - final canEdit = LocalUserAccount.current.paperlessUser.canEditDocuments; - final canDelete = LocalUserAccount.current.paperlessUser.canDeleteDocuments; + final currentUser = context.watch().paperlessUser; + final canEdit = currentUser.canEditDocuments; + final canDelete = currentUser.canDeleteDocuments; final chipShape = RoundedRectangleBorder( borderRadius: BorderRadius.circular(32), ); diff --git a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart index 1a2b51e3..b583256e 100644 --- a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart +++ b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart @@ -191,7 +191,7 @@ class _FullscreenTagsFormState extends State { final createdTag = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddTagPage( - initialValue: _textEditingController.text, + initialName: _textEditingController.text, ), ), ); 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 1339f70f..7ec30960 100644 --- a/lib/features/labels/tags/view/widgets/tags_form_field.dart +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -1,6 +1,7 @@ import 'package:animations/animations.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; @@ -73,7 +74,7 @@ class TagsFormField extends StatelessWidget { initialValue: field.value, allowOnlySelection: allowOnlySelection, allowCreation: allowCreation && - LocalUserAccount.current.paperlessUser.canCreateTags, + context.watch().paperlessUser.canCreateTags, allowExclude: allowExclude, ), onClosed: (data) { diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 71bd9a42..5aa210b3 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -7,23 +7,13 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.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/edit_label/view/impl/add_correspondent_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/edit_correspondent_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/edit_document_type_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/edit_storage_path_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/edit_tag_page.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:provider/provider.dart'; +import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; class LabelsPage extends StatefulWidget { const LabelsPage({Key? key}) : super(key: key); @@ -52,7 +42,7 @@ class _LabelsPageState extends State @override void initState() { super.initState(); - final user = LocalUserAccount.current.paperlessUser; + final user = context.read().paperlessUser; _tabController = TabController( length: _calculateTabCount(user), vsync: this) ..addListener(() => setState(() => _currentIndex = _tabController.index)); @@ -67,7 +57,7 @@ class _LabelsPageState extends State final currentUserId = Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser; + .loggedInUserId; final user = box.get(currentUserId)!.paperlessUser; return BlocBuilder( @@ -77,10 +67,14 @@ class _LabelsPageState extends State drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( onPressed: [ - if (user.canViewCorrespondents) _openAddCorrespondentPage, - if (user.canViewDocumentTypes) _openAddDocumentTypePage, - if (user.canViewTags) _openAddTagPage, - if (user.canViewStoragePaths) _openAddStoragePathPage, + if (user.canViewCorrespondents) + () => CreateLabelRoute().push(context), + if (user.canViewDocumentTypes) + () => CreateLabelRoute().push(context), + if (user.canViewTags) + () => CreateLabelRoute().push(context), + if (user.canViewStoragePaths) + () => CreateLabelRoute().push(context), ][_currentIndex], child: const Icon(Icons.add), ), @@ -213,144 +207,13 @@ class _LabelsPageState extends State controller: _tabController, children: [ if (user.canViewCorrespondents) - Builder( - builder: (context) { - return CustomScrollView( - slivers: [ - SliverOverlapInjector( - handle: searchBarHandle), - SliverOverlapInjector( - handle: tabBarHandle), - LabelTabView( - labels: state.correspondents, - filterBuilder: (label) => - DocumentFilter( - correspondent: - IdQueryParameter.fromId( - label.id!), - ), - canEdit: user.canEditCorrespondents, - canAddNew: - user.canCreateCorrespondents, - onEdit: _openEditCorrespondentPage, - emptyStateActionButtonLabel: S - .of(context)! - .addNewCorrespondent, - emptyStateDescription: S - .of(context)! - .noCorrespondentsSetUp, - onAddNew: _openAddCorrespondentPage, - ), - ], - ); - }, - ), + _buildCorrespondentsView(state, user), if (user.canViewDocumentTypes) - Builder( - builder: (context) { - return CustomScrollView( - slivers: [ - SliverOverlapInjector( - handle: searchBarHandle), - SliverOverlapInjector( - handle: tabBarHandle), - LabelTabView( - labels: state.documentTypes, - filterBuilder: (label) => - DocumentFilter( - documentType: - IdQueryParameter.fromId( - label.id!), - ), - canEdit: user.canEditDocumentTypes, - canAddNew: - user.canCreateDocumentTypes, - onEdit: _openEditDocumentTypePage, - emptyStateActionButtonLabel: S - .of(context)! - .addNewDocumentType, - emptyStateDescription: S - .of(context)! - .noDocumentTypesSetUp, - onAddNew: _openAddDocumentTypePage, - ), - ], - ); - }, - ), + _buildDocumentTypesView(state, user), if (user.canViewTags) - Builder( - builder: (context) { - return CustomScrollView( - slivers: [ - SliverOverlapInjector( - handle: searchBarHandle), - SliverOverlapInjector( - handle: tabBarHandle), - LabelTabView( - labels: state.tags, - filterBuilder: (label) => - DocumentFilter( - tags: TagsQuery.ids( - include: [label.id!]), - ), - canEdit: user.canEditTags, - canAddNew: user.canCreateTags, - onEdit: _openEditTagPage, - leadingBuilder: (t) => CircleAvatar( - backgroundColor: t.color, - child: t.isInboxTag - ? Icon( - Icons.inbox, - color: t.textColor, - ) - : null, - ), - emptyStateActionButtonLabel: - S.of(context)!.addNewTag, - emptyStateDescription: - S.of(context)!.noTagsSetUp, - onAddNew: _openAddTagPage, - ), - ], - ); - }, - ), + _buildTagsView(state, user), if (user.canViewStoragePaths) - Builder( - builder: (context) { - return CustomScrollView( - slivers: [ - SliverOverlapInjector( - handle: searchBarHandle), - SliverOverlapInjector( - handle: tabBarHandle), - LabelTabView( - labels: state.storagePaths, - onEdit: _openEditStoragePathPage, - filterBuilder: (label) => - DocumentFilter( - storagePath: - IdQueryParameter.fromId( - label.id!), - ), - canEdit: user.canEditStoragePaths, - canAddNew: - user.canCreateStoragePaths, - contentBuilder: (path) => - Text(path.path), - emptyStateActionButtonLabel: S - .of(context)! - .addNewStoragePath, - emptyStateDescription: S - .of(context)! - .noStoragePathsSetUp, - onAddNew: _openAddStoragePathPage, - ), - ], - ); - }, - ), + _buildStoragePathView(state, user), ], ), ), @@ -365,73 +228,121 @@ class _LabelsPageState extends State }); } - void _openEditCorrespondentPage(Correspondent correspondent) { - Navigator.push( - context, - _buildLabelPageRoute(EditCorrespondentPage(correspondent: correspondent)), + Widget _buildCorrespondentsView(LabelState state, UserModel user) { + return Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector(handle: searchBarHandle), + SliverOverlapInjector(handle: tabBarHandle), + LabelTabView( + labels: state.correspondents, + filterBuilder: (label) => DocumentFilter( + correspondent: IdQueryParameter.fromId(label.id!), + ), + canEdit: user.canEditCorrespondents, + canAddNew: user.canCreateCorrespondents, + onEdit: (correspondent) { + EditLabelRoute(correspondent).push(context); + }, + emptyStateActionButtonLabel: S.of(context)!.addNewCorrespondent, + emptyStateDescription: S.of(context)!.noCorrespondentsSetUp, + onAddNew: () => CreateLabelRoute().push(context), + ), + ], + ); + }, ); } - void _openEditDocumentTypePage(DocumentType docType) { - Navigator.push( - context, - _buildLabelPageRoute(EditDocumentTypePage(documentType: docType)), + Widget _buildDocumentTypesView(LabelState state, UserModel user) { + return Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector(handle: searchBarHandle), + SliverOverlapInjector(handle: tabBarHandle), + LabelTabView( + labels: state.documentTypes, + filterBuilder: (label) => DocumentFilter( + documentType: IdQueryParameter.fromId(label.id!), + ), + canEdit: user.canEditDocumentTypes, + canAddNew: user.canCreateDocumentTypes, + onEdit: (label) { + EditLabelRoute(label).push(context); + }, + emptyStateActionButtonLabel: S.of(context)!.addNewDocumentType, + emptyStateDescription: S.of(context)!.noDocumentTypesSetUp, + onAddNew: () => CreateLabelRoute().push(context), + ), + ], + ); + }, ); } - void _openEditTagPage(Tag tag) { - Navigator.push( - context, - _buildLabelPageRoute(EditTagPage(tag: tag)), + Widget _buildTagsView(LabelState state, UserModel user) { + return Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector(handle: searchBarHandle), + SliverOverlapInjector(handle: tabBarHandle), + LabelTabView( + labels: state.tags, + filterBuilder: (label) => DocumentFilter( + tags: TagsQuery.ids(include: [label.id!]), + ), + canEdit: user.canEditTags, + canAddNew: user.canCreateTags, + onEdit: (label) { + EditLabelRoute(label).push(context); + }, + leadingBuilder: (t) => CircleAvatar( + backgroundColor: t.color, + child: t.isInboxTag + ? Icon( + Icons.inbox, + color: t.textColor, + ) + : null, + ), + emptyStateActionButtonLabel: S.of(context)!.addNewTag, + emptyStateDescription: S.of(context)!.noTagsSetUp, + onAddNew: () => CreateLabelRoute().push(context), + ), + ], + ); + }, ); } - void _openEditStoragePathPage(StoragePath path) { - Navigator.push( - context, - _buildLabelPageRoute(EditStoragePathPage( - storagePath: path, - )), - ); - } - - void _openAddCorrespondentPage() { - Navigator.push( - context, - _buildLabelPageRoute(const AddCorrespondentPage()), - ); - } - - void _openAddDocumentTypePage() { - Navigator.push( - context, - _buildLabelPageRoute(const AddDocumentTypePage()), - ); - } - - void _openAddTagPage() { - Navigator.push( - context, - _buildLabelPageRoute(const AddTagPage()), - ); - } - - void _openAddStoragePathPage() { - Navigator.push( - context, - _buildLabelPageRoute(const AddStoragePathPage()), - ); - } - - MaterialPageRoute _buildLabelPageRoute(Widget page) { - return MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: context.read()), - Provider.value(value: context.read()) - ], - child: page, - ), + Widget _buildStoragePathView(LabelState state, UserModel user) { + return Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector(handle: searchBarHandle), + SliverOverlapInjector(handle: tabBarHandle), + LabelTabView( + labels: state.storagePaths, + onEdit: (label) { + EditLabelRoute(label).push(context); + }, + filterBuilder: (label) => DocumentFilter( + storagePath: IdQueryParameter.fromId(label.id!), + ), + canEdit: user.canEditStoragePaths, + canAddNew: user.canCreateStoragePaths, + contentBuilder: (path) => Text(path.path), + emptyStateActionButtonLabel: S.of(context)!.addNewStoragePath, + emptyStateDescription: S.of(context)!.noStoragePathsSetUp, + onAddNew: () => CreateLabelRoute().push(context), + ), + ], + ); + }, ); } } diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart index de59604d..ed10064f 100644 --- a/lib/features/labels/view/widgets/label_item.dart +++ b/lib/features/labels/view/widgets/label_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; @@ -36,7 +37,7 @@ class LabelItem extends StatelessWidget { Widget _buildReferencedDocumentsWidget(BuildContext context) { final canOpen = (label.documentCount ?? 0) > 0 && - LocalUserAccount.current.paperlessUser.canViewDocuments; + context.watch().paperlessUser.canViewDocuments; return TextButton.icon( label: const Icon(Icons.link), icon: Text(formatMaxCount(label.documentCount)), diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart new file mode 100644 index 00000000..18a2a9d3 --- /dev/null +++ b/lib/features/landing/view/landing_page.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/documents/cubit/documents_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +class LandingPage extends StatefulWidget { + const LandingPage({super.key}); + + @override + State createState() => _LandingPageState(); +} + +class _LandingPageState extends State { + final _searchBarHandle = SliverOverlapAbsorberHandle(); + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + drawer: const AppDrawer(), + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: _searchBarHandle, + sliver: SliverSearchBar( + floating: true, + titleText: S.of(context)!.documents, + ), + ), + ], + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: Text( + "Welcome!", + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index 889da31a..37ab278d 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; @@ -7,6 +8,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/selection/view_ import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.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/routes/typed/branches/documents_route.dart'; class LinkedDocumentsPage extends StatefulWidget { const LinkedDocumentsPage({super.key}); @@ -51,11 +53,10 @@ class _LinkedDocumentsPageState extends State isLoading: state.isLoading, hasLoaded: state.hasLoaded, onTap: (document) { - pushDocumentDetailsRoute( - context, - document: document, + DocumentDetailsRoute( + $extra: document, isLabelClickable: false, - ); + ).push(context); }, ), ], diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index fc11adfe..76b5c247 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -55,12 +55,11 @@ class AuthenticationCubit extends Cubit { // Mark logged in user as currently active user. final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.currentLoggedInUser = localUserId; + globalSettings.loggedInUserId = localUserId; await globalSettings.save(); emit( AuthenticationState.authenticated( - apiVersion: apiVersion, localUserId: localUserId, ), ); @@ -75,7 +74,8 @@ class AuthenticationCubit extends Cubit { emit(const AuthenticationState.switchingAccounts()); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - if (globalSettings.currentLoggedInUser == localUserId) { + if (globalSettings.loggedInUserId == localUserId) { + emit(AuthenticationState.authenticated(localUserId: localUserId)); return; } final userAccountBox = @@ -112,7 +112,7 @@ class AuthenticationCubit extends Cubit { baseUrl: account.serverUrl, ); - globalSettings.currentLoggedInUser = localUserId; + globalSettings.loggedInUserId = localUserId; await globalSettings.save(); final apiVersion = await _getApiVersion(_sessionManager.client); @@ -126,7 +126,6 @@ class AuthenticationCubit extends Cubit { emit(AuthenticationState.authenticated( localUserId: localUserId, - apiVersion: apiVersion, )); }); } @@ -175,13 +174,14 @@ class AuthenticationCubit extends Cubit { ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - final localUserId = globalSettings.currentLoggedInUser; + final localUserId = globalSettings.loggedInUserId; if (localUserId == null) { _debugPrintMessage( "restoreSessionState", "There is nothing to restore.", ); // If there is nothing to restore, we can quit here. + emit(const AuthenticationState.unauthenticated()); return; } final localUserAccountBox = @@ -223,7 +223,7 @@ class AuthenticationCubit extends Cubit { final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { - return box.get(globalSettings.currentLoggedInUser!); + return box.get(globalSettings.loggedInUserId!); }); if (authentication == null) { @@ -261,7 +261,6 @@ class AuthenticationCubit extends Cubit { ); emit( AuthenticationState.authenticated( - apiVersion: apiVersion, localUserId: localUserId, ), ); @@ -279,7 +278,7 @@ class AuthenticationCubit extends Cubit { await _resetExternalState(); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.currentLoggedInUser = null; + globalSettings.loggedInUserId = null; await globalSettings.save(); emit(const AuthenticationState.unauthenticated()); @@ -389,6 +388,7 @@ class AuthenticationCubit extends Cubit { settings: LocalUserSettings(), serverUrl: serverUrl, paperlessUser: serverUser, + apiVersion: apiVersion, ), ); _debugPrintMessage( diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index fbe0790d..12530aa9 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -2,12 +2,18 @@ part of 'authentication_cubit.dart'; @freezed class AuthenticationState with _$AuthenticationState { + const AuthenticationState._(); + const factory AuthenticationState.unauthenticated() = _Unauthenticated; const factory AuthenticationState.requriresLocalAuthentication() = _RequiresLocalAuthentication; const factory AuthenticationState.authenticated({ required String localUserId, - required int apiVersion, }) = _Authenticated; const factory AuthenticationState.switchingAccounts() = _SwitchingAccounts; + + bool get isAuthenticated => maybeWhen( + authenticated: (_) => true, + orElse: () => false, + ); } diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart new file mode 100644 index 00000000..72ed0293 --- /dev/null +++ b/lib/features/login/view/add_account_page.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.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/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; +import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; +import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; +import 'package:paperless_mobile/features/login/view/widgets/login_pages/server_connection_page.dart'; +import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +import 'widgets/login_pages/server_login_page.dart'; +import 'widgets/never_scrollable_scroll_behavior.dart'; + +class AddAccountPage extends StatefulWidget { + final FutureOr Function( + BuildContext context, + String username, + String password, + String serverUrl, + ClientCertificate? clientCertificate, + ) onSubmit; + + final String submitText; + final String titleString; + + final bool showLocalAccounts; + + const AddAccountPage({ + Key? key, + required this.onSubmit, + required this.submitText, + required this.titleString, + this.showLocalAccounts = false, + }) : super(key: key); + + @override + State createState() => _AddAccountPageState(); +} + +class _AddAccountPageState extends State { + final _formKey = GlobalKey(); + + final PageController _pageController = PageController(); + + @override + Widget build(BuildContext context) { + final localAccounts = + Hive.box(HiveBoxes.localUserAccount); + return Scaffold( + resizeToAvoidBottomInset: false, + body: FormBuilder( + key: _formKey, + child: PageView( + controller: _pageController, + scrollBehavior: NeverScrollableScrollBehavior(), + children: [ + if (widget.showLocalAccounts && localAccounts.isNotEmpty) + Scaffold( + appBar: AppBar( + title: Text(S.of(context)!.logInToExistingAccount), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + child: Text(S.of(context)!.goToLogin), + onPressed: () { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ], + ), + ), + body: ListView.builder( + itemBuilder: (context, index) { + final account = localAccounts.values.elementAt(index); + return Card( + child: UserAccountListTile( + account: account, + onTap: () { + context + .read() + .switchAccount(account.id); + }, + ), + ); + }, + itemCount: localAccounts.length, + ), + ), + ServerConnectionPage( + titleText: widget.titleString, + formBuilderKey: _formKey, + onContinue: () { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ServerLoginPage( + formBuilderKey: _formKey, + submitText: widget.submitText, + onSubmit: _login, + ), + ], + ), + ), + ); + } + + Future _login() async { + FocusScope.of(context).unfocus(); + if (_formKey.currentState?.saveAndValidate() ?? false) { + final form = _formKey.currentState!.value; + ClientCertificate? clientCert; + final clientCertFormModel = + form[ClientCertificateFormField.fkClientCertificate] + as ClientCertificateFormModel?; + if (clientCertFormModel != null) { + clientCert = ClientCertificate( + bytes: clientCertFormModel.bytes, + passphrase: clientCertFormModel.passphrase, + ); + } + final credentials = + form[UserCredentialsFormField.fkCredentials] as LoginFormCredentials; + try { + await widget.onSubmit( + context, + credentials.username!, + credentials.password!, + form[ServerAddressFormField.fkServerAddress], + clientCert, + ); + } on PaperlessApiException catch (error) { + showErrorMessage(context, error); + } on ServerMessageException catch (error) { + showLocalizedError(context, error.message); + } catch (error) { + showGenericError(context, error); + } + } + } +} diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 578585cb..afae0fc4 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -1,161 +1,78 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.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/database/tables/global_settings.dart'; +import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/login_pages/server_connection_page.dart'; -import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; +import 'package:paperless_mobile/features/login/view/add_account_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; + +class LoginPage extends StatelessWidget { + const LoginPage({super.key}); -import 'widgets/login_pages/server_login_page.dart'; -import 'widgets/never_scrollable_scroll_behavior.dart'; + @override + Widget build(BuildContext context) { + return AddAccountPage( + titleString: S.of(context)!.connectToPaperless, + submitText: S.of(context)!.signIn, + onSubmit: _onLogin, + showLocalAccounts: true, + ); + } -class LoginPage extends StatefulWidget { - final FutureOr Function( + void _onLogin( BuildContext context, String username, String password, String serverUrl, ClientCertificate? clientCertificate, - ) onSubmit; - - final String submitText; - final String titleString; - - final bool showLocalAccounts; - - const LoginPage({ - Key? key, - required this.onSubmit, - required this.submitText, - required this.titleString, - this.showLocalAccounts = false, - }) : super(key: key); - - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - final _formKey = GlobalKey(); - - final PageController _pageController = PageController(); - - @override - Widget build(BuildContext context) { - final localAccounts = - Hive.box(HiveBoxes.localUserAccount); - return Scaffold( - resizeToAvoidBottomInset: false, - body: FormBuilder( - key: _formKey, - child: PageView( - controller: _pageController, - scrollBehavior: NeverScrollableScrollBehavior(), - children: [ - if (widget.showLocalAccounts && localAccounts.isNotEmpty) - Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.logInToExistingAccount), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - child: Text(S.of(context)!.goToLogin), - onPressed: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ), - ), - body: ListView.builder( - itemBuilder: (context, index) { - final account = localAccounts.values.elementAt(index); - return Card( - child: UserAccountListTile( - account: account, - onTap: () { - context - .read() - .switchAccount(account.id); - }, - ), - ); - }, - itemCount: localAccounts.length, - ), - ), - ServerConnectionPage( - titleText: widget.titleString, - formBuilderKey: _formKey, - onContinue: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, + ) async { + try { + await context.read().login( + credentials: LoginFormCredentials( + username: username, + password: password, ), - ServerLoginPage( - formBuilderKey: _formKey, - submitText: widget.submitText, - onSubmit: _login, - ), - ], - ), - ), - ); - } - - Future _login() async { - FocusScope.of(context).unfocus(); - if (_formKey.currentState?.saveAndValidate() ?? false) { - final form = _formKey.currentState!.value; - ClientCertificate? clientCert; - final clientCertFormModel = - form[ClientCertificateFormField.fkClientCertificate] - as ClientCertificateFormModel?; - if (clientCertFormModel != null) { - clientCert = ClientCertificate( - bytes: clientCertFormModel.bytes, - passphrase: clientCertFormModel.passphrase, - ); + serverUrl: serverUrl, + clientCertificate: clientCertificate, + ); + // Show onboarding after first login! + final globalSettings = + Hive.box(HiveBoxes.globalSettings).getValue()!; + if (globalSettings.showOnboarding) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ApplicationIntroSlideshow(), + fullscreenDialog: true, + ), + ).then((value) { + globalSettings.showOnboarding = false; + globalSettings.save(); + }); } - final credentials = - form[UserCredentialsFormField.fkCredentials] as LoginFormCredentials; - try { - await widget.onSubmit( + // DocumentsRoute().go(context); + } on PaperlessApiException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } on PaperlessFormValidationException catch (exception, stackTrace) { + if (exception.hasUnspecificErrorMessage()) { + showLocalizedError(context, exception.unspecificErrorMessage()!); + } else { + showGenericError( context, - credentials.username!, - credentials.password!, - form[ServerAddressFormField.fkServerAddress], - clientCert, - ); - } on PaperlessApiException catch (error) { - showErrorMessage(context, error); - } on ServerMessageException catch (error) { - showLocalizedError(context, error.message); - } catch (error) { - showGenericError(context, error); + exception.validationMessages.values.first, + stackTrace, + ); //TODO: Check if we can show error message directly on field here. } + } catch (unknownError, stackTrace) { + showGenericError(context, unknownError.toString(), stackTrace); } } } diff --git a/lib/features/saved_view_details/view/saved_view_details_page.dart b/lib/features/saved_view_details/view/saved_view_details_page.dart index d82bd21f..352e7a23 100644 --- a/lib/features/saved_view_details/view/saved_view_details_page.dart +++ b/lib/features/saved_view_details/view/saved_view_details_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; @@ -9,6 +10,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/selection/confi import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class SavedViewDetailsPage extends StatefulWidget { final Future Function(SavedView savedView) onDelete; @@ -28,7 +30,7 @@ class _SavedViewDetailsPageState extends State @override Widget build(BuildContext context) { - final cubit = context.read(); + final cubit = context.watch(); return Scaffold( appBar: AppBar( title: Text(cubit.savedView.name), @@ -76,11 +78,10 @@ class _SavedViewDetailsPageState extends State isLoading: state.isLoading, hasLoaded: state.hasLoaded, onTap: (document) { - pushDocumentDetailsRoute( - context, - document: document, + DocumentDetailsRoute( + $extra: document, isLabelClickable: false, - ); + ).push(context); }, viewType: state.viewType, ), diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index 35921446..1d412625 100644 --- a/lib/features/settings/view/manage_accounts_page.dart +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -6,7 +6,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; -import 'package:paperless_mobile/features/login/view/login_page.dart'; +import 'package:paperless_mobile/features/login/view/add_account_page.dart'; import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; @@ -22,7 +22,7 @@ class ManageAccountsPage extends StatelessWidget { builder: (context, globalSettings) { // This is one of the few places where the currentLoggedInUser can be null // (exactly after loggin out as the current user to be precise). - if (globalSettings.currentLoggedInUser == null) { + if (globalSettings.loggedInUserId == null) { return const SizedBox.shrink(); } return ValueListenableBuilder( @@ -32,8 +32,7 @@ class ManageAccountsPage extends StatelessWidget { builder: (context, box, _) { final userIds = box.keys.toList().cast(); final otherAccounts = userIds - .whereNot( - (element) => element == globalSettings.currentLoggedInUser) + .whereNot((element) => element == globalSettings.loggedInUserId) .toList(); return SimpleDialog( insetPadding: const EdgeInsets.all(24), @@ -54,7 +53,7 @@ class ManageAccountsPage extends StatelessWidget { children: [ Card( child: UserAccountListTile( - account: box.get(globalSettings.currentLoggedInUser!)!, + account: box.get(globalSettings.loggedInUserId!)!, trailing: PopupMenuButton( icon: const Icon(Icons.more_vert), itemBuilder: (context) => [ @@ -71,8 +70,7 @@ class ManageAccountsPage extends StatelessWidget { ], onSelected: (value) async { if (value == 0) { - final currentUser = - globalSettings.currentLoggedInUser!; + final currentUser = globalSettings.loggedInUserId!; await context.read().logout(); Navigator.of(context).pop(); await context @@ -117,7 +115,7 @@ class ManageAccountsPage extends StatelessWidget { // Switch _onSwitchAccount( context, - globalSettings.currentLoggedInUser!, + globalSettings.loggedInUserId!, otherAccounts[index], ); } else if (value == 1) { @@ -135,10 +133,10 @@ class ManageAccountsPage extends StatelessWidget { title: Text(S.of(context)!.addAccount), leading: const Icon(Icons.person_add), onTap: () { - _onAddAccount(context, globalSettings.currentLoggedInUser!); + _onAddAccount(context, globalSettings.loggedInUserId!); }, ), - if (context.watch().hasMultiUserSupport) + if (context.watch().hasMultiUserSupport) ListTile( leading: const Icon(Icons.admin_panel_settings), title: Text(S.of(context)!.managePermissions), @@ -155,7 +153,7 @@ class ManageAccountsPage extends StatelessWidget { final userId = await Navigator.push( context, MaterialPageRoute( - builder: (context) => LoginPage( + builder: (context) => AddAccountPage( titleString: S.of(context)!.addAccount, onSubmit: (context, username, password, serverUrl, clientCertificate) async { diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 2740172e..a0d5ce44 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -100,14 +100,4 @@ class SettingsPage extends StatelessWidget { ), ); } - - void _goto(Widget page, BuildContext context) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => page, - maintainState: true, - ), - ); - } } diff --git a/lib/features/settings/view/widgets/user_settings_builder.dart b/lib/features/settings/view/widgets/user_settings_builder.dart index 6512efef..2201a584 100644 --- a/lib/features/settings/view/widgets/user_settings_builder.dart +++ b/lib/features/settings/view/widgets/user_settings_builder.dart @@ -23,7 +23,7 @@ class UserAccountBuilder extends StatelessWidget { builder: (context, accountBox, _) { final currentUser = Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser; + .loggedInUserId; if (currentUser != null) { final account = accountBox.get(currentUser); return builder(context, account); diff --git a/lib/features/similar_documents/view/similar_documents_view.dart b/lib/features/similar_documents/view/similar_documents_view.dart index 35564c49..3219fe70 100644 --- a/lib/features/similar_documents/view/similar_documents_view.dart +++ b/lib/features/similar_documents/view/similar_documents_view.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class SimilarDocumentsView extends StatefulWidget { final ScrollController pagingScrollController; @@ -64,11 +64,10 @@ class _SimilarDocumentsViewState extends State hasLoaded: state.hasLoaded, enableHeroAnimation: false, onTap: (document) { - pushDocumentDetailsRoute( - context, - document: document, + DocumentDetailsRoute( + $extra: document, isLabelClickable: false, - ); + ).push(context); }, ); }, diff --git a/lib/main.dart b/lib/main.dart index d86dd734..2eebcf17 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; @@ -30,19 +31,23 @@ import 'package:paperless_mobile/core/interceptor/language_header.interceptor.da import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; -import 'package:paperless_mobile/features/home/view/home_route.dart'; -import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; -import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; -import 'package:paperless_mobile/features/login/view/login_page.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; -import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; +import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:paperless_mobile/theme.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -138,7 +143,9 @@ void main() async { }); final apiFactory = PaperlessApiFactoryImpl(sessionManager); - + final authenticationCubit = + AuthenticationCubit(localAuthService, apiFactory, sessionManager); + await authenticationCubit.restoreSessionState(); runApp( MultiProvider( providers: [ @@ -154,13 +161,10 @@ void main() async { child: MultiBlocProvider( providers: [ BlocProvider.value(value: connectivityCubit), - BlocProvider( - create: (context) => AuthenticationCubit( - localAuthService, apiFactory, sessionManager), - ), + BlocProvider.value(value: authenticationCubit), ], - child: PaperlessMobileEntrypoint( - paperlessProviderFactory: apiFactory, + child: GoRouterShell( + apiFactory: apiFactory, ), ), ), @@ -182,70 +186,69 @@ void main() async { }); } -class PaperlessMobileEntrypoint extends StatefulWidget { - final PaperlessApiFactory paperlessProviderFactory; - const PaperlessMobileEntrypoint({ - Key? key, - required this.paperlessProviderFactory, - }) : super(key: key); - - @override - State createState() => - _PaperlessMobileEntrypointState(); -} +// class PaperlessMobileEntrypoint extends StatefulWidget { +// final PaperlessApiFactory paperlessProviderFactory; +// const PaperlessMobileEntrypoint({ +// Key? key, +// required this.paperlessProviderFactory, +// }) : super(key: key); -class _PaperlessMobileEntrypointState extends State { - @override - Widget build(BuildContext context) { - return GlobalSettingsBuilder( - builder: (context, settings) { - return DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) { - return MaterialApp( - debugShowCheckedModeBanner: true, - title: "Paperless Mobile", - theme: buildTheme( - brightness: Brightness.light, - dynamicScheme: lightDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - darkTheme: buildTheme( - brightness: Brightness.dark, - dynamicScheme: darkDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - themeMode: settings.preferredThemeMode, - supportedLocales: S.supportedLocales, - locale: Locale.fromSubtags( - languageCode: settings.preferredLocaleSubtag, - ), - localizationsDelegates: const [ - ...S.localizationsDelegates, - ], - home: AuthenticationWrapper( - paperlessProviderFactory: widget.paperlessProviderFactory, - ), - ); - }, - ); - }, - ); - } -} +// @override +// State createState() => +// _PaperlessMobileEntrypointState(); +// } -class AuthenticationWrapper extends StatefulWidget { - final PaperlessApiFactory paperlessProviderFactory; +// class _PaperlessMobileEntrypointState extends State { +// @override +// Widget build(BuildContext context) { +// return GlobalSettingsBuilder( +// builder: (context, settings) { +// return DynamicColorBuilder( +// builder: (lightDynamic, darkDynamic) { +// return MaterialApp( +// debugShowCheckedModeBanner: true, +// title: "Paperless Mobile", +// theme: buildTheme( +// brightness: Brightness.light, +// dynamicScheme: lightDynamic, +// preferredColorScheme: settings.preferredColorSchemeOption, +// ), +// darkTheme: buildTheme( +// brightness: Brightness.dark, +// dynamicScheme: darkDynamic, +// preferredColorScheme: settings.preferredColorSchemeOption, +// ), +// themeMode: settings.preferredThemeMode, +// supportedLocales: S.supportedLocales, +// locale: Locale.fromSubtags( +// languageCode: settings.preferredLocaleSubtag, +// ), +// localizationsDelegates: const [ +// ...S.localizationsDelegates, +// ], +// home: AuthenticationWrapper( +// paperlessProviderFactory: widget.paperlessProviderFactory, +// ), +// ); +// }, +// ); +// }, +// ); +// } +// } - const AuthenticationWrapper({ - Key? key, - required this.paperlessProviderFactory, - }) : super(key: key); +class GoRouterShell extends StatefulWidget { + final PaperlessApiFactory apiFactory; + const GoRouterShell({ + super.key, + required this.apiFactory, + }); @override - State createState() => _AuthenticationWrapperState(); + State createState() => _GoRouterShellState(); } -class _AuthenticationWrapperState extends State { +class _GoRouterShellState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); @@ -257,7 +260,6 @@ class _AuthenticationWrapperState extends State { @override void initState() { super.initState(); - // Activate the highest supported refresh rate on the device if (Platform.isAndroid) { _setOptimalDisplayMode(); @@ -281,75 +283,180 @@ class _AuthenticationWrapperState extends State { await FlutterDisplayMode.setPreferredMode(mostOptimalMode); } + late final _router = GoRouter( + debugLogDiagnostics: true, + initialLocation: "/login", + routes: [ + $loginRoute, + $verifyIdentityRoute, + $switchingAccountsRoute, + $settingsRoute, + ShellRoute( + navigatorKey: rootNavigatorKey, + builder: ProviderShellRoute(widget.apiFactory).build, + routes: [ + // GoRoute( + // parentNavigatorKey: rootNavigatorKey, + // name: R.savedView, + // path: "/saved_view/:id", + // builder: (context, state) { + // return Placeholder( + // child: Text("Documents"), + // ); + // }, + // routes: [ + // GoRoute( + // path: "create", + // name: R.createSavedView, + // builder: (context, state) { + // return Placeholder( + // child: Text("Documents"), + // ); + // }, + // ), + // ], + // ), + StatefulShellRoute.indexedStack( + builder: const ScaffoldShellRoute().builder, + branches: [ + StatefulShellBranch( + navigatorKey: landingNavigatorKey, + routes: [$landingRoute], + ), + StatefulShellBranch( + navigatorKey: documentsNavigatorKey, + routes: [$documentsRoute], + ), + StatefulShellBranch( + navigatorKey: scannerNavigatorKey, + routes: [$scannerRoute], + ), + StatefulShellBranch( + navigatorKey: labelsNavigatorKey, + routes: [$labelsRoute], + ), + StatefulShellBranch( + navigatorKey: inboxNavigatorKey, + routes: [$inboxRoute], + ), + ], + ), + ], + ), + ], + ); + @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, authentication) { - return authentication.when( - unauthenticated: () => LoginPage( - titleString: S.of(context)!.connectToPaperless, - submitText: S.of(context)!.signIn, - onSubmit: _onLogin, - showLocalAccounts: true, - ), - requriresLocalAuthentication: () => const VerifyIdentityPage(), - authenticated: (localUserId, apiVersion) => HomeRoute( - key: ValueKey(localUserId), - paperlessApiVersion: apiVersion, - paperlessProviderFactory: widget.paperlessProviderFactory, - localUserId: localUserId, - ), - switchingAccounts: () => const SwitchingAccountsPage(), + return GlobalSettingsBuilder( + builder: (context, settings) { + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return BlocListener( + listener: (context, state) { + state.when( + unauthenticated: () => const LoginRoute().go(context), + requriresLocalAuthentication: () => + const VerifyIdentityRoute().go(context), + authenticated: (localUserId) => + const LandingRoute().go(context), + switchingAccounts: () => + const SwitchingAccountsRoute().go(context), + ); + }, + child: MaterialApp.router( + routerConfig: _router, + debugShowCheckedModeBanner: true, + title: "Paperless Mobile", + theme: buildTheme( + brightness: Brightness.light, + dynamicScheme: lightDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + darkTheme: buildTheme( + brightness: Brightness.dark, + dynamicScheme: darkDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + themeMode: settings.preferredThemeMode, + supportedLocales: S.supportedLocales, + locale: Locale.fromSubtags( + languageCode: settings.preferredLocaleSubtag, + ), + localizationsDelegates: S.localizationsDelegates, + ), + ); + }, ); }, ); } - - void _onLogin( - BuildContext context, - String username, - String password, - String serverUrl, - ClientCertificate? clientCertificate, - ) async { - try { - await context.read().login( - credentials: LoginFormCredentials( - username: username, - password: password, - ), - serverUrl: serverUrl, - clientCertificate: clientCertificate, - ); - // Show onboarding after first login! - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; - if (globalSettings.showOnboarding) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const ApplicationIntroSlideshow(), - fullscreenDialog: true, - ), - ).then((value) { - globalSettings.showOnboarding = false; - globalSettings.save(); - }); - } - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } on PaperlessFormValidationException catch (exception, stackTrace) { - if (exception.hasUnspecificErrorMessage()) { - showLocalizedError(context, exception.unspecificErrorMessage()!); - } else { - showGenericError( - context, - exception.validationMessages.values.first, - stackTrace, - ); //TODO: Check if we can show error message directly on field here. - } - } catch (unknownError, stackTrace) { - showGenericError(context, unknownError.toString(), stackTrace); - } - } } + +// class AuthenticationWrapper extends StatefulWidget { +// final PaperlessApiFactory paperlessProviderFactory; + +// const AuthenticationWrapper({ +// Key? key, +// required this.paperlessProviderFactory, +// }) : super(key: key); + +// @override +// State createState() => _AuthenticationWrapperState(); +// } + +// class _AuthenticationWrapperState extends State { +// @override +// void didChangeDependencies() { +// super.didChangeDependencies(); +// context.read().restoreSessionState().then((value) { +// FlutterNativeSplash.remove(); +// }); +// } + +// @override +// void initState() { +// super.initState(); + +// // Activate the highest supported refresh rate on the device +// if (Platform.isAndroid) { +// _setOptimalDisplayMode(); +// } +// initializeDateFormatting(); +// } + +// Future _setOptimalDisplayMode() async { +// final List supported = await FlutterDisplayMode.supported; +// final DisplayMode active = await FlutterDisplayMode.active; + +// final List sameResolution = supported +// .where((m) => m.width == active.width && m.height == active.height) +// .toList() +// ..sort((a, b) => b.refreshRate.compareTo(a.refreshRate)); + +// final DisplayMode mostOptimalMode = +// sameResolution.isNotEmpty ? sameResolution.first : active; +// debugPrint('Setting refresh rate to ${mostOptimalMode.refreshRate}'); + +// await FlutterDisplayMode.setPreferredMode(mostOptimalMode); +// } + +// @override +// Widget build(BuildContext context) { +// return BlocBuilder( +// builder: (context, authentication) { +// return authentication.when( +// unauthenticated: () => const LoginPage(), +// requriresLocalAuthentication: () => const VerifyIdentityPage(), +// authenticated: (localUserId, apiVersion) => HomeShellWidget( +// key: ValueKey(localUserId), +// paperlessApiVersion: apiVersion, +// paperlessProviderFactory: widget.paperlessProviderFactory, +// localUserId: localUserId, +// ), +// switchingAccounts: () => const SwitchingAccountsPage(), +// ); +// }, +// ); +// } +// } diff --git a/lib/routes/document_details_route.dart b/lib/routes/document_details_route.dart deleted file mode 100644 index a90001ef..00000000 --- a/lib/routes/document_details_route.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; -import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; - -class DocumentDetailsRoute extends StatelessWidget { - final DocumentModel document; - final bool isLabelClickable; - final String? titleAndContentQueryString; - - const DocumentDetailsRoute({ - super.key, - required this.document, - this.isLabelClickable = true, - this.titleAndContentQueryString, - }); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => DocumentDetailsCubit( - context.read(), - context.read(), - context.read(), - context.read(), - initialDocument: document, - ), - lazy: false, - child: DocumentDetailsPage( - isLabelClickable: isLabelClickable, - titleAndContentQueryString: titleAndContentQueryString, - ), - ); - } -} - -class DocumentDetailsRouteArguments { - final DocumentModel document; - final bool isLabelClickable; - final bool allowEdit; - final String? titleAndContentQueryString; - - DocumentDetailsRouteArguments({ - required this.document, - this.isLabelClickable = true, - this.allowEdit = true, - this.titleAndContentQueryString, - }); -} diff --git a/lib/routes/navigation_keys.dart b/lib/routes/navigation_keys.dart new file mode 100644 index 00000000..fc6cbd41 --- /dev/null +++ b/lib/routes/navigation_keys.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +final rootNavigatorKey = GlobalKey(); +final landingNavigatorKey = GlobalKey(); +final documentsNavigatorKey = GlobalKey(); +final scannerNavigatorKey = GlobalKey(); +final labelsNavigatorKey = GlobalKey(); +final inboxNavigatorKey = GlobalKey(); diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart new file mode 100644 index 00000000..291ff434 --- /dev/null +++ b/lib/routes/routes.dart @@ -0,0 +1,20 @@ +class R { + const R._(); + static const landing = "landing"; + static const login = "login"; + static const documents = "documents"; + static const verifyIdentity = "verifyIdentity"; + static const switchingAccounts = "switchingAccounts"; + static const savedView = "savedView"; + static const createSavedView = "createSavedView"; + static const documentDetails = "documentDetails"; + static const editDocument = "editDocument"; + static const labels = "labels"; + static const createLabel = "createLabel"; + static const editLabel = "editLabel"; + static const scanner = "scanner"; + static const uploadDocument = "upload"; + static const inbox = "inbox"; + static const documentPreview = "documentPreview"; + static const settings = "settings"; +} diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart new file mode 100644 index 00000000..61748381 --- /dev/null +++ b/lib/routes/typed/branches/documents_route.dart @@ -0,0 +1,113 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter/widgets.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; +import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; +import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart'; +import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; +import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'documents_route.g.dart'; + +class DocumentsBranch extends StatefulShellBranchData { + static final GlobalKey $navigatorKey = documentsNavigatorKey; + const DocumentsBranch(); +} + +@TypedGoRoute( + path: "/documents", + name: R.documents, + routes: [ + TypedGoRoute( + path: "edit", + name: R.editDocument, + ), + TypedGoRoute( + path: "details", + name: R.documentDetails, + ), + TypedGoRoute( + path: "preview", + name: R.documentPreview, + ) + ], +) +class DocumentsRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const DocumentsPage(); + } +} + +class DocumentDetailsRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + final bool isLabelClickable; + final DocumentModel $extra; + final String? queryString; + + const DocumentDetailsRoute({ + required this.$extra, + this.isLabelClickable = true, + this.queryString, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (_) => DocumentDetailsCubit( + context.read(), + context.read(), + context.read(), + context.read(), + initialDocument: $extra, + ), + lazy: false, + child: DocumentDetailsPage( + isLabelClickable: isLabelClickable, + titleAndContentQueryString: queryString, + ), + ); + } +} + +class EditDocumentRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + final DocumentModel $extra; + + const EditDocumentRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (context) => DocumentEditCubit( + context.read(), + context.read(), + context.read(), + document: $extra, + )..loadFieldSuggestions(), + child: const DocumentEditPage(), + ); + } +} + +class DocumentPreviewRoute extends GoRouteData { + final DocumentModel $extra; + const DocumentPreviewRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return DocumentView( + documentBytes: context.read().download($extra), + ); + } +} diff --git a/lib/routes/typed/branches/inbox_route.dart b/lib/routes/typed/branches/inbox_route.dart new file mode 100644 index 00000000..48f038f7 --- /dev/null +++ b/lib/routes/typed/branches/inbox_route.dart @@ -0,0 +1,17 @@ +import 'package:flutter/src/widgets/framework.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'inbox_route.g.dart'; + +@TypedGoRoute( + path: "/inbox", + name: R.inbox, +) +class InboxRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const InboxPage(); + } +} diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart new file mode 100644 index 00000000..e1deac84 --- /dev/null +++ b/lib/routes/typed/branches/labels_route.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/edit_correspondent_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/edit_document_type_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/edit_storage_path_page.dart'; +import 'package:paperless_mobile/features/edit_label/view/impl/edit_tag_page.dart'; +import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'labels_route.g.dart'; + +class LabelsBranch extends StatefulShellBranchData { + static final GlobalKey $navigatorKey = labelsNavigatorKey; + const LabelsBranch(); +} + +@TypedGoRoute( + path: "/labels", + name: R.labels, + routes: [ + TypedGoRoute( + path: "edit", + name: R.editLabel, + ), + TypedGoRoute( + path: "create", + name: R.createLabel, + ), + ], +) +class LabelsRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const LabelsPage(); + } +} + +class EditLabelRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + final Label $extra; + + const EditLabelRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return switch ($extra) { + Correspondent c => EditCorrespondentPage(correspondent: c), + DocumentType d => EditDocumentTypePage(documentType: d), + Tag t => EditTagPage(tag: t), + StoragePath s => EditStoragePathPage(storagePath: s), + }; + } +} + +class CreateLabelRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + final String? name; + + CreateLabelRoute({ + this.name, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + if (T is Correspondent) { + return AddCorrespondentPage(initialName: name); + } else if (T is DocumentType) { + return AddDocumentTypePage(initialName: name); + } else if (T is Tag) { + return AddTagPage(initialName: name); + } else if (T is StoragePath) { + return AddStoragePathPage(initialName: name); + } + throw ArgumentError(); + } +} diff --git a/lib/routes/typed/branches/landing_route.dart b/lib/routes/typed/branches/landing_route.dart new file mode 100644 index 00000000..78c58bae --- /dev/null +++ b/lib/routes/typed/branches/landing_route.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/landing/view/landing_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'landing_route.g.dart'; + +class LandingBranch extends StatefulShellBranchData { + static final GlobalKey $navigatorKey = landingNavigatorKey; + + const LandingBranch(); +} + +@TypedGoRoute( + path: "/landing", + name: R.landing, + routes: [ + TypedGoRoute( + path: "saved-view", + name: R.savedView, + ), + ], +) +class LandingRoute extends GoRouteData { + const LandingRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return const LandingPage(); + } +} + +class SavedViewRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return Placeholder(); + } +} diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart new file mode 100644 index 00000000..4bfb01d2 --- /dev/null +++ b/lib/routes/typed/branches/scanner_route.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/document_scan/view/scanner_page.dart'; +import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'scanner_route.g.dart'; + +// @TypedStatefulShellBranch( +// routes: [ +// TypedGoRoute( +// path: "/scanner", +// name: R.scanner, +// routes: [ +// TypedGoRoute( +// path: "upload", +// name: R.uploadDocument, +// ), +// ], +// ), +// ], +// ) +class ScannerBranch extends StatefulShellBranchData { + static final GlobalKey $navigatorKey = scannerNavigatorKey; + + const ScannerBranch(); +} + +@TypedGoRoute( + path: "/scanner", + name: R.scanner, + routes: [ + TypedGoRoute( + path: "upload", + name: R.uploadDocument, + ), + ], +) +class ScannerRoute extends GoRouteData { + const ScannerRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const ScannerPage(); + } +} + +class DocumentUploadRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + final Uint8List $extra; + final String? title; + final String? filename; + final String? fileExtension; + + const DocumentUploadRoute({ + required this.$extra, + this.title, + this.filename, + this.fileExtension, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (_) => DocumentUploadCubit( + context.read(), + context.read(), + context.read(), + ), + child: DocumentUploadPreparationPage( + title: title, + fileExtension: fileExtension, + filename: filename, + fileBytes: $extra, + ), + ); + } +} diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart new file mode 100644 index 00000000..99f29a40 --- /dev/null +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/features/home/view/home_shell_widget.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; + +//part 'provider_shell_route.g.dart'; +//TODO: Wait for https://github.com/flutter/flutter/issues/127371 to be merged +// @TypedShellRoute( +// routes: [ +// TypedStatefulShellRoute( +// branches: [ +// TypedStatefulShellBranch( +// routes: [ +// TypedGoRoute( +// path: "/landing", +// // name: R.landing, +// ) +// ], +// ), +// TypedStatefulShellBranch( +// routes: [ +// TypedGoRoute( +// path: "/documents", +// routes: [ +// TypedGoRoute( +// path: "details", +// // name: R.documentDetails, +// ), +// TypedGoRoute( +// path: "edit", +// // name: R.editDocument, +// ), +// ], +// ) +// ], +// ), +// ], +// ), +// ], +// ) +class ProviderShellRoute extends ShellRouteData { + final PaperlessApiFactory apiFactory; + static final GlobalKey $navigatorKey = rootNavigatorKey; + + const ProviderShellRoute(this.apiFactory); + + Widget build( + BuildContext context, + GoRouterState state, + Widget navigator, + ) { + final currentUserId = Hive.box(HiveBoxes.globalSettings) + .getValue()! + .loggedInUserId!; + final authenticatedUser = + Hive.box(HiveBoxes.localUserAccount).get( + currentUserId, + )!; + return HomeShellWidget( + localUserId: authenticatedUser.id, + paperlessApiVersion: authenticatedUser.apiVersion, + paperlessProviderFactory: apiFactory, + child: navigator, + ); + } +} diff --git a/lib/routes/typed/shells/scaffold_shell_route.dart b/lib/routes/typed/shells/scaffold_shell_route.dart new file mode 100644 index 00000000..cd865897 --- /dev/null +++ b/lib/routes/typed/shells/scaffold_shell_route.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/features/home/view/scaffold_with_navigation_bar.dart'; + +class ScaffoldShellRoute extends StatefulShellRouteData { + const ScaffoldShellRoute(); + @override + Widget builder( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + final currentUserId = Hive.box(HiveBoxes.globalSettings) + .getValue()! + .loggedInUserId!; + final authenticatedUser = + Hive.box(HiveBoxes.localUserAccount).get( + currentUserId, + )!; + return ScaffoldWithNavigationBar( + authenticatedUser: authenticatedUser.paperlessUser, + navigationShell: navigationShell, + ); + } +} diff --git a/lib/routes/typed/top_level/login_route.dart b/lib/routes/typed/top_level/login_route.dart new file mode 100644 index 00000000..ce6cc8fb --- /dev/null +++ b/lib/routes/typed/top_level/login_route.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/view/login_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'login_route.g.dart'; + +@TypedGoRoute( + path: "/login", + name: R.login, +) +class LoginRoute extends GoRouteData { + const LoginRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return const LoginPage(); + } + + @override + FutureOr redirect(BuildContext context, GoRouterState state) { + if (context.read().state.isAuthenticated) { + return "/landing"; + } + return null; + } +} diff --git a/lib/routes/typed/top_level/settings_route.dart b/lib/routes/typed/top_level/settings_route.dart new file mode 100644 index 00000000..0b0664ce --- /dev/null +++ b/lib/routes/typed/top_level/settings_route.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/settings/view/settings_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'settings_route.g.dart'; + +@TypedGoRoute( + path: "/settings", + name: R.settings, +) +class SettingsRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const SettingsPage(); + } +} diff --git a/lib/routes/typed/top_level/switching_accounts_route.dart b/lib/routes/typed/top_level/switching_accounts_route.dart new file mode 100644 index 00000000..1dc74dc1 --- /dev/null +++ b/lib/routes/typed/top_level/switching_accounts_route.dart @@ -0,0 +1,18 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'switching_accounts_route.g.dart'; + +@TypedGoRoute( + path: '/switching-accounts', + name: R.switchingAccounts, +) +class SwitchingAccountsRoute extends GoRouteData { + const SwitchingAccountsRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return const SwitchingAccountsPage(); + } +} diff --git a/lib/routes/typed/top_level/verify_identity_route.dart b/lib/routes/typed/top_level/verify_identity_route.dart new file mode 100644 index 00000000..5e62dd10 --- /dev/null +++ b/lib/routes/typed/top_level/verify_identity_route.dart @@ -0,0 +1,19 @@ +import 'package:go_router/go_router.dart'; +import 'package:flutter/widgets.dart'; +import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'verify_identity_route.g.dart'; + +@TypedGoRoute( + path: '/verify-identity', + name: R.verifyIdentity, +) +class VerifyIdentityRoute extends GoRouteData { + const VerifyIdentityRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const VerifyIdentityPage(); + } +} diff --git a/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart index 51cc0669..1cad789e 100644 --- a/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart +++ b/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'paperless_server_exception.g.dart'; +part 'paperless_server_message_exception.g.dart'; @JsonSerializable(createToJson: false) class PaperlessServerMessageException implements Exception { @@ -13,5 +13,5 @@ class PaperlessServerMessageException implements Exception { } factory PaperlessServerMessageException.fromJson(Map json) => - _$PaperlessServerExceptionFromJson(json); + _$PaperlessServerMessageExceptionFromJson(json); } diff --git a/packages/paperless_api/lib/src/models/labels/correspondent_model.dart b/packages/paperless_api/lib/src/models/labels/correspondent_model.dart deleted file mode 100644 index 7a97202c..00000000 --- a/packages/paperless_api/lib/src/models/labels/correspondent_model.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/src/converters/local_date_time_json_converter.dart'; -import 'package:paperless_api/src/models/labels/label_model.dart'; -import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; - -part 'correspondent_model.g.dart'; - -@LocalDateTimeJsonConverter() -@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) -class Correspondent extends Label { - final DateTime? lastCorrespondence; - - const Correspondent({ - this.lastCorrespondence, - required super.name, - super.id, - super.slug, - super.match, - super.matchingAlgorithm, - super.isInsensitive, - super.documentCount, - super.owner, - super.userCanChange, - }); - - factory Correspondent.fromJson(Map json) => - _$CorrespondentFromJson(json); - - @override - Map toJson() => _$CorrespondentToJson(this); - - @override - String toString() { - return name; - } - - @override - Correspondent copyWith({ - int? id, - String? name, - String? slug, - String? match, - MatchingAlgorithm? matchingAlgorithm, - bool? isInsensitive, - int? documentCount, - DateTime? lastCorrespondence, - }) { - return Correspondent( - id: id ?? this.id, - name: name ?? this.name, - documentCount: documentCount ?? documentCount, - isInsensitive: isInsensitive ?? isInsensitive, - match: match ?? this.match, - matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, - slug: slug ?? this.slug, - lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence, - ); - } - - @override - String get queryEndpoint => 'correspondents'; - - @override - List get props => [ - id, - name, - slug, - isInsensitive, - documentCount, - lastCorrespondence, - matchingAlgorithm, - match, - ]; -} diff --git a/packages/paperless_api/lib/src/models/labels/document_type_model.dart b/packages/paperless_api/lib/src/models/labels/document_type_model.dart deleted file mode 100644 index efd72850..00000000 --- a/packages/paperless_api/lib/src/models/labels/document_type_model.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/src/models/labels/label_model.dart'; -import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; -part 'document_type_model.g.dart'; - -@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) -class DocumentType extends Label { - const DocumentType({ - super.id, - required super.name, - super.slug, - super.match, - super.matchingAlgorithm, - super.isInsensitive, - super.documentCount, - super.owner, - super.userCanChange, - }); - - factory DocumentType.fromJson(Map json) => _$DocumentTypeFromJson(json); - - @override - String get queryEndpoint => 'document_types'; - - @override - DocumentType copyWith({ - int? id, - String? name, - String? match, - MatchingAlgorithm? matchingAlgorithm, - bool? isInsensitive, - int? documentCount, - String? slug, - }) { - return DocumentType( - id: id ?? this.id, - name: name ?? this.name, - match: match ?? this.match, - matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, - isInsensitive: isInsensitive ?? this.isInsensitive, - documentCount: documentCount ?? this.documentCount, - slug: slug ?? this.slug, - ); - } - - @override - Map toJson() => _$DocumentTypeToJson(this); - - @override - List get props => [ - id, - name, - slug, - isInsensitive, - documentCount, - matchingAlgorithm, - match, - ]; -} diff --git a/packages/paperless_api/lib/src/models/labels/label_model.dart b/packages/paperless_api/lib/src/models/labels/label_model.dart index ee9c2b86..59706964 100644 --- a/packages/paperless_api/lib/src/models/labels/label_model.dart +++ b/packages/paperless_api/lib/src/models/labels/label_model.dart @@ -1,7 +1,14 @@ +import 'dart:ui'; + import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/src/converters/hex_color_json_converter.dart'; +import 'package:paperless_api/src/converters/local_date_time_json_converter.dart'; import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; -abstract class Label extends Equatable implements Comparable { +part 'label_model.g.dart'; + +sealed class Label extends Equatable implements Comparable { static const idKey = "id"; static const nameKey = "name"; static const slugKey = "slug"; @@ -56,3 +63,278 @@ abstract class Label extends Equatable implements Comparable { Map toJson(); } + +@LocalDateTimeJsonConverter() +@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) +class Correspondent extends Label { + final DateTime? lastCorrespondence; + + const Correspondent({ + this.lastCorrespondence, + required super.name, + super.id, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + super.owner, + super.userCanChange, + }); + + factory Correspondent.fromJson(Map json) => + _$CorrespondentFromJson(json); + + @override + Map toJson() => _$CorrespondentToJson(this); + + @override + String toString() { + return name; + } + + @override + Correspondent copyWith({ + int? id, + String? name, + String? slug, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + DateTime? lastCorrespondence, + }) { + return Correspondent( + id: id ?? this.id, + name: name ?? this.name, + documentCount: documentCount ?? documentCount, + isInsensitive: isInsensitive ?? isInsensitive, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + slug: slug ?? this.slug, + lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence, + ); + } + + @override + String get queryEndpoint => 'correspondents'; + + @override + List get props => [ + id, + name, + slug, + isInsensitive, + documentCount, + lastCorrespondence, + matchingAlgorithm, + match, + ]; +} + +@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) +class DocumentType extends Label { + const DocumentType({ + super.id, + required super.name, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + super.owner, + super.userCanChange, + }); + + factory DocumentType.fromJson(Map json) => + _$DocumentTypeFromJson(json); + + @override + String get queryEndpoint => 'document_types'; + + @override + DocumentType copyWith({ + int? id, + String? name, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? slug, + }) { + return DocumentType( + id: id ?? this.id, + name: name ?? this.name, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + isInsensitive: isInsensitive ?? this.isInsensitive, + documentCount: documentCount ?? this.documentCount, + slug: slug ?? this.slug, + ); + } + + @override + Map toJson() => _$DocumentTypeToJson(this); + + @override + List get props => [ + id, + name, + slug, + isInsensitive, + documentCount, + matchingAlgorithm, + match, + ]; +} + +@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) +class StoragePath extends Label { + static const pathKey = 'path'; + final String path; + + const StoragePath({ + super.id, + required super.name, + required this.path, + super.slug, + super.match, + super.matchingAlgorithm, + super.isInsensitive, + super.documentCount, + super.owner, + super.userCanChange, + }); + + factory StoragePath.fromJson(Map json) => + _$StoragePathFromJson(json); + + @override + String toString() { + return name; + } + + @override + StoragePath copyWith({ + int? id, + String? name, + String? slug, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? path, + }) { + return StoragePath( + id: id ?? this.id, + name: name ?? this.name, + documentCount: documentCount ?? documentCount, + isInsensitive: isInsensitive ?? isInsensitive, + path: path ?? this.path, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + slug: slug ?? this.slug, + ); + } + + @override + String get queryEndpoint => 'storage_paths'; + + @override + List get props => [ + id, + name, + slug, + isInsensitive, + documentCount, + path, + matchingAlgorithm, + match, + ]; + + @override + Map toJson() => _$StoragePathToJson(this); +} + +@HexColorJsonConverter() +@JsonSerializable( + fieldRename: FieldRename.snake, + explicitToJson: true, +) +class Tag extends Label { + static const colorKey = 'color'; + static const isInboxTagKey = 'is_inbox_tag'; + static const textColorKey = 'text_color'; + static const legacyColourKey = 'colour'; + final Color? textColor; + final Color? color; + + final bool isInboxTag; + + const Tag({ + super.id, + required super.name, + super.documentCount, + super.isInsensitive, + super.match, + super.matchingAlgorithm = MatchingAlgorithm.defaultValue, + super.slug, + this.color, + this.textColor, + this.isInboxTag = false, + super.owner, + super.userCanChange, + }); + + @override + String toString() => name; + + @override + Tag copyWith({ + int? id, + String? name, + String? match, + MatchingAlgorithm? matchingAlgorithm, + bool? isInsensitive, + int? documentCount, + String? slug, + Color? color, + Color? textColor, + bool? isInboxTag, + }) { + return Tag( + id: id ?? this.id, + name: name ?? this.name, + match: match ?? this.match, + matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, + isInsensitive: isInsensitive ?? this.isInsensitive, + documentCount: documentCount ?? this.documentCount, + slug: slug ?? this.slug, + color: color ?? this.color, + textColor: textColor ?? this.textColor, + isInboxTag: isInboxTag ?? this.isInboxTag, + ); + } + + @override + String get queryEndpoint => 'tags'; + + @override + List get props => [ + id, + name, + slug, + isInsensitive, + documentCount, + matchingAlgorithm, + color, + textColor, + isInboxTag, + match, + ]; + + factory Tag.fromJson(Map json) => _$TagFromJson(json); + + @override + Map toJson() => _$TagToJson(this); +} diff --git a/packages/paperless_api/lib/src/models/labels/storage_path_model.dart b/packages/paperless_api/lib/src/models/labels/storage_path_model.dart deleted file mode 100644 index b05e53ea..00000000 --- a/packages/paperless_api/lib/src/models/labels/storage_path_model.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/src/models/labels/label_model.dart'; -import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; -part 'storage_path_model.g.dart'; - -@JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) -class StoragePath extends Label { - static const pathKey = 'path'; - final String path; - - const StoragePath({ - super.id, - required super.name, - required this.path, - super.slug, - super.match, - super.matchingAlgorithm, - super.isInsensitive, - super.documentCount, - super.owner, - super.userCanChange, - }); - - factory StoragePath.fromJson(Map json) => _$StoragePathFromJson(json); - - @override - String toString() { - return name; - } - - @override - StoragePath copyWith({ - int? id, - String? name, - String? slug, - String? match, - MatchingAlgorithm? matchingAlgorithm, - bool? isInsensitive, - int? documentCount, - String? path, - }) { - return StoragePath( - id: id ?? this.id, - name: name ?? this.name, - documentCount: documentCount ?? documentCount, - isInsensitive: isInsensitive ?? isInsensitive, - path: path ?? this.path, - match: match ?? this.match, - matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, - slug: slug ?? this.slug, - ); - } - - @override - String get queryEndpoint => 'storage_paths'; - - @override - List get props => [ - id, - name, - slug, - isInsensitive, - documentCount, - path, - matchingAlgorithm, - match, - ]; - - @override - Map toJson() => _$StoragePathToJson(this); -} diff --git a/packages/paperless_api/lib/src/models/labels/tag_model.dart b/packages/paperless_api/lib/src/models/labels/tag_model.dart deleted file mode 100644 index 60528096..00000000 --- a/packages/paperless_api/lib/src/models/labels/tag_model.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:ui'; - -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/src/converters/hex_color_json_converter.dart'; -import 'package:paperless_api/src/models/labels/label_model.dart'; -import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; - -part 'tag_model.g.dart'; - -@HexColorJsonConverter() -@JsonSerializable( - fieldRename: FieldRename.snake, - explicitToJson: true, -) -class Tag extends Label { - static const colorKey = 'color'; - static const isInboxTagKey = 'is_inbox_tag'; - static const textColorKey = 'text_color'; - static const legacyColourKey = 'colour'; - final Color? textColor; - final Color? color; - - final bool isInboxTag; - - const Tag({ - super.id, - required super.name, - super.documentCount, - super.isInsensitive, - super.match, - super.matchingAlgorithm = MatchingAlgorithm.defaultValue, - super.slug, - this.color, - this.textColor, - this.isInboxTag = false, - super.owner, - super.userCanChange, - }); - - @override - String toString() => name; - - @override - Tag copyWith({ - int? id, - String? name, - String? match, - MatchingAlgorithm? matchingAlgorithm, - bool? isInsensitive, - int? documentCount, - String? slug, - Color? color, - Color? textColor, - bool? isInboxTag, - }) { - return Tag( - id: id ?? this.id, - name: name ?? this.name, - match: match ?? this.match, - matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm, - isInsensitive: isInsensitive ?? this.isInsensitive, - documentCount: documentCount ?? this.documentCount, - slug: slug ?? this.slug, - color: color ?? this.color, - textColor: textColor ?? this.textColor, - isInboxTag: isInboxTag ?? this.isInboxTag, - ); - } - - @override - String get queryEndpoint => 'tags'; - - @override - List get props => [ - id, - name, - slug, - isInsensitive, - documentCount, - matchingAlgorithm, - color, - textColor, - isInboxTag, - match, - ]; - - factory Tag.fromJson(Map json) => _$TagFromJson(json); - - @override - Map toJson() => _$TagToJson(this); -} diff --git a/packages/paperless_api/lib/src/models/models.dart b/packages/paperless_api/lib/src/models/models.dart index d9ebaa85..e5b84954 100644 --- a/packages/paperless_api/lib/src/models/models.dart +++ b/packages/paperless_api/lib/src/models/models.dart @@ -5,12 +5,8 @@ export 'document_model.dart'; export 'field_suggestions.dart'; export 'filter_rule_model.dart'; export 'group_model.dart'; -export 'labels/correspondent_model.dart'; -export 'labels/document_type_model.dart'; export 'labels/label_model.dart'; export 'labels/matching_algorithm.dart'; -export 'labels/storage_path_model.dart'; -export 'labels/tag_model.dart'; export 'paged_search_result.dart'; export 'paperless_api_exception.dart'; export 'paperless_server_information_model.dart'; diff --git a/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart b/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart index 87b49351..0a6921ff 100644 --- a/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart +++ b/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart @@ -81,4 +81,10 @@ extension UserPermissionExtension on UserModel { hasPermission(PermissionAction.add, PermissionTarget.storagePath); bool get canCreateSavedViews => hasPermission(PermissionAction.add, PermissionTarget.savedView); + + bool get canViewAnyLabel => + canViewCorrespondents || + canViewDocumentTypes || + canViewTags || + canViewStoragePaths; } diff --git a/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api.dart b/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api.dart index b373e552..eb5f5434 100644 --- a/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api.dart +++ b/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api.dart @@ -1,7 +1,5 @@ -import 'package:paperless_api/src/models/labels/correspondent_model.dart'; -import 'package:paperless_api/src/models/labels/document_type_model.dart'; -import 'package:paperless_api/src/models/labels/storage_path_model.dart'; -import 'package:paperless_api/src/models/labels/tag_model.dart'; + +import 'package:paperless_api/src/models/models.dart'; /// /// Provides basic CRUD operations for labels, including: diff --git a/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api_impl.dart b/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api_impl.dart index e45dbf8c..740e7131 100644 --- a/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/labels_api/paperless_labels_api_impl.dart @@ -3,10 +3,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:paperless_api/src/extensions/dio_exception_extension.dart'; -import 'package:paperless_api/src/models/labels/correspondent_model.dart'; -import 'package:paperless_api/src/models/labels/document_type_model.dart'; -import 'package:paperless_api/src/models/labels/storage_path_model.dart'; -import 'package:paperless_api/src/models/labels/tag_model.dart'; +import 'package:paperless_api/src/models/models.dart'; import 'package:paperless_api/src/models/paperless_api_exception.dart'; import 'package:paperless_api/src/modules/labels_api/paperless_labels_api.dart'; import 'package:paperless_api/src/request_utils.dart'; diff --git a/pubspec.lock b/pubspec.lock index 16734631..7c385445 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,10 +317,10 @@ packages: dependency: "direct dev" description: name: dart_code_metrics - sha256: "1dc1fa763b73ed52147bd91b015d81903edc3f227b77b1672fcddba43390ed18" + sha256: "3dede3f7abc077a4181ec7445448a289a9ce08e2981e6a4d49a3fb5099d47e1f" url: "https://pub.dev" source: hosted - version: "5.7.5" + version: "5.7.6" dart_code_metrics_presets: dependency: transitive description: @@ -381,18 +381,18 @@ packages: dependency: "direct main" description: name: dio - sha256: a9d76e72985d7087eb7c5e7903224ae52b337131518d127c554b9405936752b8 + sha256: "3866d67f93523161b643187af65f5ac08bc991a5bcdaf41a2d587fe4ccb49993" url: "https://pub.dev" source: hosted - version: "5.2.1+1" + version: "5.3.0" dots_indicator: dependency: transitive description: name: dots_indicator - sha256: "58b6a365744aa62aa1b70c4ea29e5106fbe064f5edaf7e9652e9b856edbfd9bb" + sha256: f1599baa429936ba87f06ae5f2adc920a367b16d08f74db58c3d0f6e93bcdb5c url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.1.2" dynamic_color: dependency: "direct main" description: @@ -727,18 +727,18 @@ packages: dependency: "direct dev" description: name: freezed - sha256: a9520490532087cf38bf3f7de478ab6ebeb5f68bb1eb2641546d92719b224445 + sha256: "2df89855fe181baae3b6d714dc3c4317acf4fccd495a6f36e5e00f24144c6c3b" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.4.1" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338 + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -760,6 +760,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b3cadd2cd59a4103fd5f6bc572ca75111264698784e927aa471921c3477d5475 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + go_router_builder: + dependency: "direct dev" + description: + name: go_router_builder + sha256: df2034629637d0c7c380aba5daa2f91be4733f2d632e7dff0b082d5ff3155068 + url: "https://pub.dev" + source: hosted + version: "2.2.4" graphs: dependency: transitive description: @@ -865,10 +881,10 @@ packages: dependency: "direct main" description: name: introduction_screen - sha256: f39be426026785b8fea4ed93e226e7fc28ef49a4c78c3f86c958bae26dabef00 + sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 url: "https://pub.dev" source: hosted - version: "3.1.9" + version: "3.1.11" io: dependency: transitive description: @@ -1143,10 +1159,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "1995d88ec2948dac43edf8fe58eb434d35d22a2940ecee1a9fefcd62beee6eb3" + sha256: "916731ccbdce44d545414dd9961f26ba5fbaa74bcbb55237d8e65a623a8c7297" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" path_provider_linux: dependency: transitive description: @@ -1183,34 +1199,34 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "415af30ba76a84faccfe1eb251fe1e4fdc790f876924c65ad7d6ed7a1404bcd6" + sha256: "63e5216aae014a72fe9579ccd027323395ce7a98271d9defa9d57320d001af81" url: "https://pub.dev" source: hosted - version: "10.4.2" + version: "10.4.3" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "3b61f3da3b1c83bc3fb6a2b431e8dab01d0e5b45f6a3d9c7609770ec88b2a89e" + sha256: "2ffaf52a21f64ac9b35fe7369bb9533edbd4f698e5604db8645b1064ff4cf221" url: "https://pub.dev" source: hosted - version: "10.3.0" + version: "10.3.3" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "7a187b671a39919462af2b5e813148365b71a615979165a119868d667fe90c03" + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" url: "https://pub.dev" source: hosted - version: "9.1.3" + version: "9.1.4" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "463a07cb7cc6c758a7a1c7da36ce666bb80a0b4b5e92df0fa36872e0ed456993" + sha256: "7c6b1500385dd1d2ca61bb89e2488ca178e274a69144d26bbd65e33eae7c02a9" url: "https://pub.dev" source: hosted - version: "3.11.1" + version: "3.11.3" permission_handler_windows: dependency: transitive description: @@ -1247,10 +1263,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: "43798d895c929056255600343db8f049921cbec94d31ec87f1dc5c16c01935dd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pointer_interceptor: dependency: transitive description: @@ -1468,10 +1484,10 @@ packages: dependency: "direct main" description: name: sliver_tools - sha256: ccdc502098a8bfa07b3ec582c282620031481300035584e1bb3aca296a505e8c + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 url: "https://pub.dev" source: hosted - version: "0.2.10" + version: "0.2.12" source_gen: dependency: transitive description: @@ -1516,18 +1532,18 @@ packages: dependency: transitive description: name: sqflite - sha256: b4d6710e1200e96845747e37338ea8a819a12b51689a3bcf31eff0003b37a0b9 + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" url: "https://pub.dev" source: hosted - version: "2.2.8+4" + version: "2.3.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "8f7603f3f8f126740bc55c4ca2d1027aab4b74a1267a3e31ce51fe40e3b65b8f" + sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" url: "https://pub.dev" source: hosted - version: "2.4.5+1" + version: "2.5.0" stack_trace: dependency: transitive description: @@ -1652,10 +1668,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "15f5acbf0dce90146a0f5a2c4a002b1814a6303c4c5c075aa2623b2d16156f03" + sha256: "78cb6dea3e93148615109e58e42c35d1ffbf5ef66c44add673d0ab75f12ff3af" url: "https://pub.dev" source: hosted - version: "6.0.36" + version: "6.0.37" url_launcher_ios: dependency: transitive description: @@ -1676,10 +1692,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e" + sha256: "1c4fdc0bfea61a70792ce97157e5cc17260f61abbe4f39354513f39ec6fd73b1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" url_launcher_platform_interface: dependency: transitive description: @@ -1772,26 +1788,26 @@ packages: dependency: transitive description: name: webview_flutter_android - sha256: "1c93e96f3069bacdc734fad6b7e1d3a480fd516a3ae5b8858becf7f07515a2f3" + sha256: d936a09fbfd08cb78f7329e0bbacf6158fbdfe24ffc908b22444c07d295eb193 url: "https://pub.dev" source: hosted - version: "3.8.2" + version: "3.9.2" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "656e2aeaef318900fffd21468b6ddc7958c7092a642f0e7220bac328b70d4a81" + sha256: "564ef378cafc1a0e29f1d76ce175ef517a0a6115875dff7b43fccbef2b0aeb30" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: a8d7e8b4be2a79e83b70235369971ec97d14df4cdbb40d305a8eeae67d8e6432 + sha256: "5fa098f28b606f699e8ca52d9e4e11edbbfef65189f5f77ae92703ba5408fd25" url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "3.7.2" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3b44c501..cba64964 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: webview_flutter: ^4.2.1 printing: ^5.11.0 flutter_pdfview: ^1.3.1 + go_router: ^10.0.0 dependency_overrides: intl: ^0.18.1 @@ -113,6 +114,7 @@ dev_dependencies: hive_generator: ^2.0.0 mock_server: path: packages/mock_server + go_router_builder: ^2.2.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec From f3e660e91d175066ec42d611bbc4fcfd349ea033 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Mon, 31 Jul 2023 02:51:00 +0200 Subject: [PATCH 02/12] feat: Further migrations to go_router, add onclick to document previews --- lib/core/navigation/push_routes.dart | 111 +---- .../fullscreen_bulk_edit_label_page.dart | 6 +- .../fullscreen_bulk_edit_tags_widget.dart | 4 +- .../view/pages/document_details_page.dart | 94 ++-- .../widgets/document_meta_data_widget.dart | 9 +- .../widgets/document_overview_widget.dart | 113 +++-- .../view/document_edit_page.dart | 264 +++++----- .../document_scan/view/scanner_page.dart | 104 ++-- .../view/document_search_bar.dart | 35 +- .../view/sliver_search_bar.dart | 5 +- .../document_upload_preparation_page.dart | 449 +++++++++++------- .../documents/view/pages/documents_page.dart | 4 +- .../view/widgets/document_preview.dart | 20 +- .../widgets/search/document_filter_panel.dart | 1 + .../document_selection_sliver_app_bar.dart | 24 +- .../edit_label/view/edit_label_page.dart | 5 +- lib/features/edit_label/view/label_form.dart | 4 +- lib/features/home/view/home_shell_widget.dart | 27 +- .../view/scaffold_with_navigation_bar.dart | 18 +- lib/features/inbox/view/pages/inbox_page.dart | 1 + .../view/widgets/fullscreen_tags_form.dart | 1 + .../labels/view/pages/labels_page.dart | 23 +- .../labels/view/widgets/label_item.dart | 3 +- .../labels/view/widgets/label_text.dart | 2 +- lib/features/landing/view/landing_page.dart | 4 - .../saved_view/view/add_saved_view_page.dart | 5 +- .../view/saved_view_details_page.dart | 2 +- lib/main.dart | 178 +------ lib/routes/routes.dart | 2 + .../typed/branches/documents_route.dart | 118 ++++- lib/routes/typed/branches/labels_route.dart | 49 +- .../lib/src/models/labels/label_model.dart | 7 + .../example/lib/scan.dart | 9 +- 33 files changed, 862 insertions(+), 839 deletions(-) diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart index 77e5312f..e1d9fe20 100644 --- a/lib/core/navigation/push_routes.dart +++ b/lib/core/navigation/push_routes.dart @@ -1,15 +1,8 @@ -import 'dart:typed_data'; - -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; @@ -18,14 +11,7 @@ import 'package:paperless_mobile/core/repository/user_repository.dart'; import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart'; import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart'; -import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; -import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; -import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; -import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; -import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; -import 'package:paperless_mobile/features/linked_documents/view/linked_documents_page.dart'; -import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; @@ -33,29 +19,6 @@ import 'package:paperless_mobile/features/saved_view_details/view/saved_view_det import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; -// These are convenience methods for nativating to views without having to pass providers around explicitly. -// Providers unfortunately have to be passed to the routes since they are children of the Navigator, not ancestors. - -Future pushDocumentSearchPage(BuildContext context) { - final currentUser = Hive.box(HiveBoxes.globalSettings) - .getValue()! - .loggedInUserId; - final userRepo = context.read(); - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider( - create: (context) => DocumentSearchCubit( - context.read(), - context.read(), - Hive.box(HiveBoxes.localUserAppState) - .get(currentUser)!, - ), - child: const DocumentSearchPage(), - ), - ), - ); -} - Future pushSavedViewDetailsRoute( BuildContext context, { required SavedView savedView, @@ -84,7 +47,8 @@ Future pushSavedViewDetailsRoute( savedView: savedView, ), child: SavedViewDetailsPage( - onDelete: context.read().remove), + onDelete: context.read().remove, + ), ); }, ), @@ -107,39 +71,6 @@ Future pushAddSavedViewRoute(BuildContext context, ); } -Future pushLinkedDocumentsView( - BuildContext context, { - required DocumentFilter filter, -}) { - return Navigator.push( - context, - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - if (context.watch().hasMultiUserSupport) - Provider.value(value: context.read()), - ], - builder: (context, _) => BlocProvider( - create: (context) => LinkedDocumentsCubit( - filter, - context.read(), - context.read(), - context.read(), - ), - child: const LinkedDocumentsPage(), - ), - ), - ), - ); -} - Future pushBulkEditCorrespondentRoute( BuildContext context, { required List selection, @@ -306,44 +237,6 @@ Future pushBulkEditDocumentTypeRoute(BuildContext context, ); } -Future pushDocumentUploadPreparationPage( - BuildContext context, { - required Uint8List bytes, - String? filename, - String? fileExtension, - String? title, -}) { - final labelRepo = context.read(); - final docsApi = context.read(); - final connectivity = context.read(); - final apiVersion = context.read(); - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: labelRepo), - Provider.value(value: docsApi), - Provider.value(value: connectivity), - Provider.value(value: apiVersion) - ], - builder: (_, child) => BlocProvider( - create: (_) => DocumentUploadCubit( - context.read(), - context.read(), - context.read(), - ), - child: DocumentUploadPreparationPage( - fileBytes: bytes, - fileExtension: fileExtension, - filename: filename, - title: title, - ), - ), - ), - ), - ); -} - List _getRequiredBulkEditProviders(BuildContext context) { return [ Provider.value(value: context.read()), diff --git a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart index 2d2a7a60..33dee5e6 100644 --- a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart +++ b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; @@ -86,6 +87,7 @@ class _FullscreenBulkEditLabelPageState selectionCount: _labels.length, floatingActionButton: !hideFab ? FloatingActionButton.extended( + heroTag: "fab_fullscreen_bulk_edit_label", onPressed: _onSubmit, label: Text(S.of(context)!.apply), icon: const Icon(Icons.done), @@ -122,7 +124,7 @@ class _FullscreenBulkEditLabelPageState void _onSubmit() async { if (_selection == null) { - Navigator.pop(context); + context.pop(); } else { bool shouldPerformAction; if (_selection!.label == null) { @@ -148,7 +150,7 @@ class _FullscreenBulkEditLabelPageState } if (shouldPerformAction) { widget.onSubmit(_selection!.label); - Navigator.pop(context); + context.pop(); } } } diff --git a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart index 12485214..dbeb107b 100644 --- a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart +++ b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; @@ -74,6 +75,7 @@ class _FullscreenBulkEditTagsWidgetState controller: _controller, floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty ? FloatingActionButton.extended( + heroTag: "fab_fullscreen_bulk_edit_tags", label: Text(S.of(context)!.apply), icon: const Icon(Icons.done), onPressed: _submit, @@ -173,7 +175,7 @@ class _FullscreenBulkEditTagsWidgetState removeTagIds: _removeTags, addTagIds: _addTags, ); - Navigator.pop(context); + context.pop(); } } } 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 79216fb0..4f4249e3 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; @@ -92,37 +93,43 @@ class _DocumentDetailsPageState extends State { DocumentDetailsState>( builder: (context, state) { return Positioned.fill( - child: DocumentPreview( - document: state.document, - fit: BoxFit.cover, + child: GestureDetector( + onTap: () { + DocumentPreviewRoute($extra: state.document) + .push(context); + }, + child: DocumentPreview( + document: state.document, + fit: BoxFit.cover, + ), ), ); }, ), - Positioned.fill( - top: 0, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context) - .colorScheme - .background - .withOpacity(0.8), - Theme.of(context) - .colorScheme - .background - .withOpacity(0.5), - Colors.transparent, - Colors.transparent, - Colors.transparent, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ), + // Positioned.fill( + // top: -kToolbarHeight, + // child: DecoratedBox( + // decoration: BoxDecoration( + // gradient: LinearGradient( + // colors: [ + // Theme.of(context) + // .colorScheme + // .background + // .withOpacity(0.8), + // Theme.of(context) + // .colorScheme + // .background + // .withOpacity(0.5), + // Colors.transparent, + // Colors.transparent, + // Colors.transparent, + // ], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + // ), + // ), + // ), ], ), ), @@ -302,6 +309,7 @@ class _DocumentDetailsPageState extends State { preferBelow: false, verticalOffset: 40, child: FloatingActionButton( + heroTag: "fab_document_details", child: const Icon(Icons.edit), onPressed: () => EditDocumentRoute(state.document).push(context), ), @@ -333,13 +341,13 @@ class _DocumentDetailsPageState extends State { document: state.document, enabled: isConnected, ), - //TODO: Enable again, need new pdf viewer package... - IconButton( - tooltip: S.of(context)!.previewTooltip, - icon: const Icon(Icons.visibility), - onPressed: - (isConnected) ? () => _onOpen(state.document) : null, - ).paddedOnly(right: 4.0), + // //TODO: Enable again, need new pdf viewer package... + // IconButton( + // tooltip: S.of(context)!.previewTooltip, + // icon: const Icon(Icons.visibility), + // onPressed: + // (isConnected) ? () => _onOpen(state.document) : null, + // ).paddedOnly(right: 4.0), IconButton( tooltip: S.of(context)!.openInSystemViewer, icon: const Icon(Icons.open_in_new), @@ -391,21 +399,17 @@ class _DocumentDetailsPageState extends State { } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } finally { - // Document deleted => go back to primary route - Navigator.popUntil(context, (route) => route.isFirst); + do { + context.pop(); + } while (context.canPop()); } } } Future _onOpen(DocumentModel document) async { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DocumentView( - documentBytes: - context.read().download(document), - title: document.title, - ), - ), - ); + DocumentPreviewRoute( + $extra: document, + title: document.title, + ).push(context); } } diff --git a/lib/features/document_details/view/widgets/document_meta_data_widget.dart b/lib/features/document_details/view/widgets/document_meta_data_widget.dart index 8eae4f8f..bdfcf814 100644 --- a/lib/features/document_details/view/widgets/document_meta_data_widget.dart +++ b/lib/features/document_details/view/widgets/document_meta_data_widget.dart @@ -2,6 +2,7 @@ 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/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; @@ -25,6 +26,7 @@ class DocumentMetaDataWidget extends StatefulWidget { class _DocumentMetaDataWidgetState extends State { @override Widget build(BuildContext context) { + final currentUser = context.watch().paperlessUser; return BlocBuilder( builder: (context, state) { if (state.metaData == null) { @@ -37,9 +39,10 @@ class _DocumentMetaDataWidgetState extends State { return SliverList( delegate: SliverChildListDelegate( [ - ArchiveSerialNumberField( - document: widget.document, - ).paddedOnly(bottom: widget.itemSpacing), + if (currentUser.canEditDocuments) + ArchiveSerialNumberField( + document: widget.document, + ).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text( DateFormat().format(widget.document.modified), context: context, diff --git a/lib/features/document_details/view/widgets/document_overview_widget.dart b/lib/features/document_details/view/widgets/document_overview_widget.dart index b19b49af..f38d2661 100644 --- a/lib/features/document_details/view/widgets/document_overview_widget.dart +++ b/lib/features/document_details/view/widgets/document_overview_widget.dart @@ -31,71 +31,66 @@ class DocumentOverviewWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SliverList( - delegate: SliverChildListDelegate( - [ + return SliverList.list( + children: [ + DetailsItem( + label: S.of(context)!.title, + content: HighlightedText( + text: document.title, + highlights: queryString?.split(" ") ?? [], + style: Theme.of(context).textTheme.bodyLarge, + ), + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + DateFormat.yMMMMd().format(document.created), + context: context, + label: S.of(context)!.createdAt, + ).paddedOnly(bottom: itemSpacing), + if (document.documentType != null && + context + .watch() + .paperlessUser + .canViewDocumentTypes) DetailsItem( - label: S.of(context)!.title, - content: HighlightedText( - text: document.title, - highlights: queryString?.split(" ") ?? [], + label: S.of(context)!.documentType, + content: LabelText( style: Theme.of(context).textTheme.bodyLarge, + label: availableDocumentTypes[document.documentType], ), ).paddedOnly(bottom: itemSpacing), - DetailsItem.text( - DateFormat.yMMMMd().format(document.created), - context: context, - label: S.of(context)!.createdAt, + if (document.correspondent != null && + context + .watch() + .paperlessUser + .canViewCorrespondents) + DetailsItem( + label: S.of(context)!.correspondent, + content: LabelText( + style: Theme.of(context).textTheme.bodyLarge, + label: availableCorrespondents[document.correspondent], + ), ).paddedOnly(bottom: itemSpacing), - if (document.documentType != null && - context - .watch() - .paperlessUser - .canViewDocumentTypes) - DetailsItem( - label: S.of(context)!.documentType, - content: LabelText( - style: Theme.of(context).textTheme.bodyLarge, - label: availableDocumentTypes[document.documentType], - ), - ).paddedOnly(bottom: itemSpacing), - if (document.correspondent != null && - context - .watch() - .paperlessUser - .canViewCorrespondents) - DetailsItem( - label: S.of(context)!.correspondent, - content: LabelText( - style: Theme.of(context).textTheme.bodyLarge, - label: availableCorrespondents[document.correspondent], - ), - ).paddedOnly(bottom: itemSpacing), - if (document.storagePath != null && - context - .watch() - .paperlessUser - .canViewStoragePaths) - DetailsItem( - label: S.of(context)!.storagePath, - content: LabelText( - label: availableStoragePaths[document.storagePath], - ), - ).paddedOnly(bottom: itemSpacing), - if (document.tags.isNotEmpty && - context.watch().paperlessUser.canViewTags) - DetailsItem( - label: S.of(context)!.tags, - content: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: TagsWidget( - isClickable: false, - tags: document.tags.map((e) => availableTags[e]!).toList(), - ), + if (document.storagePath != null && + context.watch().paperlessUser.canViewStoragePaths) + DetailsItem( + label: S.of(context)!.storagePath, + content: LabelText( + label: availableStoragePaths[document.storagePath], + ), + ).paddedOnly(bottom: itemSpacing), + if (document.tags.isNotEmpty && + context.watch().paperlessUser.canViewTags) + DetailsItem( + label: S.of(context)!.tags, + content: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TagsWidget( + isClickable: false, + tags: document.tags.map((e) => availableTags[e]!).toList(), ), - ).paddedOnly(bottom: itemSpacing), - ], - ), + ), + ).paddedOnly(bottom: itemSpacing), + ], ); } } diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 8cb014df..8453c24c 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -44,6 +45,7 @@ class _DocumentEditPageState extends State { @override Widget build(BuildContext context) { + final currentUser = context.watch().paperlessUser; return BlocBuilder( builder: (context, state) { final filteredSuggestions = state.suggestions?.documentDifference( @@ -53,6 +55,7 @@ class _DocumentEditPageState extends State { child: Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_document_edit", onPressed: () => _onSubmit(state.document), icon: const Icon(Icons.save), label: Text(S.of(context)!.saveChanges), @@ -90,147 +93,146 @@ class _DocumentEditPageState extends State { filteredSuggestions, ).padded(), // Correspondent form field - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddCorrespondentPage( - initialName: initialValue, + if (currentUser.canViewCorrespondents) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddCorrespondentPage( + initialName: initialValue, + ), ), + addLabelText: S.of(context)!.addCorrespondent, + labelText: S.of(context)!.correspondent, + options: context + .watch() + .state + .correspondents, + initialValue: + state.document.correspondent != null + ? IdQueryParameter.fromId( + state.document.correspondent!) + : const IdQueryParameter.unset(), + name: fkCorrespondent, + prefixIcon: const Icon(Icons.person_outlined), + allowSelectUnassigned: true, + canCreateNewLabel: + currentUser.canCreateCorrespondents, ), - addLabelText: S.of(context)!.addCorrespondent, - labelText: S.of(context)!.correspondent, - options: context - .watch() - .state - .correspondents, - initialValue: - state.document.correspondent != null - ? IdQueryParameter.fromId( - state.document.correspondent!) - : const IdQueryParameter.unset(), - name: fkCorrespondent, - prefixIcon: const Icon(Icons.person_outlined), - allowSelectUnassigned: true, - canCreateNewLabel: context - .watch() - .paperlessUser - .canCreateCorrespondents, - ), - if (filteredSuggestions - ?.hasSuggestedCorrespondents ?? - false) - _buildSuggestionsSkeleton( - suggestions: - filteredSuggestions!.correspondents, - itemBuilder: (context, itemData) => - ActionChip( - label: Text( - state.correspondents[itemData]!.name), - onPressed: () { - _formKey - .currentState?.fields[fkCorrespondent] - ?.didChange( - IdQueryParameter.fromId(itemData), - ); - }, + if (filteredSuggestions + ?.hasSuggestedCorrespondents ?? + false) + _buildSuggestionsSkeleton( + suggestions: + filteredSuggestions!.correspondents, + itemBuilder: (context, itemData) => + ActionChip( + label: Text( + state.correspondents[itemData]!.name), + onPressed: () { + _formKey.currentState + ?.fields[fkCorrespondent] + ?.didChange( + IdQueryParameter.fromId(itemData), + ); + }, + ), ), - ), - ], - ).padded(), + ], + ).padded(), // DocumentType form field - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (currentInput) => - RepositoryProvider.value( - value: context.read(), - child: AddDocumentTypePage( - initialName: currentInput, + if (currentUser.canViewDocumentTypes) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (currentInput) => + RepositoryProvider.value( + value: context.read(), + child: AddDocumentTypePage( + initialName: currentInput, + ), ), + canCreateNewLabel: + currentUser.canCreateDocumentTypes, + addLabelText: S.of(context)!.addDocumentType, + labelText: S.of(context)!.documentType, + initialValue: + state.document.documentType != null + ? IdQueryParameter.fromId( + state.document.documentType!) + : const IdQueryParameter.unset(), + options: state.documentTypes, + name: _DocumentEditPageState.fkDocumentType, + prefixIcon: + const Icon(Icons.description_outlined), + allowSelectUnassigned: true, ), - canCreateNewLabel: context - .watch() - .paperlessUser - .canCreateDocumentTypes, - addLabelText: S.of(context)!.addDocumentType, - labelText: S.of(context)!.documentType, - initialValue: - state.document.documentType != null - ? IdQueryParameter.fromId( - state.document.documentType!) - : const IdQueryParameter.unset(), - options: state.documentTypes, - name: _DocumentEditPageState.fkDocumentType, - prefixIcon: - const Icon(Icons.description_outlined), - allowSelectUnassigned: true, - ), - if (filteredSuggestions - ?.hasSuggestedDocumentTypes ?? - false) - _buildSuggestionsSkeleton( - suggestions: - filteredSuggestions!.documentTypes, - itemBuilder: (context, itemData) => - ActionChip( - label: Text( - state.documentTypes[itemData]!.name), - onPressed: () => _formKey - .currentState?.fields[fkDocumentType] - ?.didChange( - IdQueryParameter.fromId(itemData), + if (filteredSuggestions + ?.hasSuggestedDocumentTypes ?? + false) + _buildSuggestionsSkeleton( + suggestions: + filteredSuggestions!.documentTypes, + itemBuilder: (context, itemData) => + ActionChip( + label: Text( + state.documentTypes[itemData]!.name), + onPressed: () => _formKey + .currentState?.fields[fkDocumentType] + ?.didChange( + IdQueryParameter.fromId(itemData), + ), ), ), - ), - ], - ).padded(), + ], + ).padded(), // StoragePath form field - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddStoragePathPage( - initialName: initialValue), + if (currentUser.canViewStoragePaths) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddStoragePathPage( + initialName: initialValue), + ), + canCreateNewLabel: + currentUser.canCreateStoragePaths, + addLabelText: S.of(context)!.addStoragePath, + labelText: S.of(context)!.storagePath, + options: state.storagePaths, + initialValue: + state.document.storagePath != null + ? IdQueryParameter.fromId( + state.document.storagePath!) + : const IdQueryParameter.unset(), + name: fkStoragePath, + prefixIcon: const Icon(Icons.folder_outlined), + allowSelectUnassigned: true, ), - canCreateNewLabel: context - .watch() - .paperlessUser - .canCreateStoragePaths, - addLabelText: S.of(context)!.addStoragePath, - labelText: S.of(context)!.storagePath, - options: state.storagePaths, - initialValue: state.document.storagePath != null - ? IdQueryParameter.fromId( - state.document.storagePath!) - : const IdQueryParameter.unset(), - name: fkStoragePath, - prefixIcon: const Icon(Icons.folder_outlined), - allowSelectUnassigned: true, - ), - ], - ).padded(), + ], + ).padded(), // Tag form field - TagsFormField( - options: state.tags, - name: fkTags, - allowOnlySelection: true, - allowCreation: true, - allowExclude: false, - initialValue: TagsQuery.ids( - include: state.document.tags.toList(), - ), - ).padded(), + if (currentUser.canViewTags) + TagsFormField( + options: state.tags, + name: fkTags, + allowOnlySelection: true, + allowCreation: true, + allowExclude: false, + initialValue: TagsQuery.ids( + include: state.document.tags.toList(), + ), + ).padded(), if (filteredSuggestions?.tags .toSet() .difference(state.document.tags.toSet()) @@ -321,7 +323,7 @@ class _DocumentEditPageState extends State { setState(() { _isSubmitLoading = false; }); - Navigator.pop(context); + context.pop(); } } } diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 71aed2b4..fca17f97 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -22,11 +22,13 @@ import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_c import 'package:paperless_mobile/features/document_scan/view/widgets/export_scans_dialog.dart'; import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_image_item.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; @@ -53,58 +55,52 @@ class _ScannerPageState extends State Widget build(BuildContext context) { return BlocBuilder( builder: (context, connectedState) { - return Scaffold( - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - onPressed: () => _openDocumentScanner(context), - child: const Icon(Icons.add_a_photo_outlined), - ), - body: BlocBuilder>( - builder: (context, state) { - return SafeArea( - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - onPressed: () => _openDocumentScanner(context), - child: const Icon(Icons.add_a_photo_outlined), - ), - body: NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: SliverSearchBar( - titleText: S.of(context)!.scanner, - ), + return BlocBuilder>( + builder: (context, state) { + return SafeArea( + child: Scaffold( + drawer: const AppDrawer(), + floatingActionButton: FloatingActionButton( + heroTag: "fab_document_edit", + onPressed: () => _openDocumentScanner(context), + child: const Icon(Icons.add_a_photo_outlined), + ), + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: SliverSearchBar( + titleText: S.of(context)!.scanner, ), - SliverOverlapAbsorber( - handle: actionsHandle, - sliver: SliverPinnedHeader( - child: _buildActions(connectedState.isConnected), - ), + ), + SliverOverlapAbsorber( + handle: actionsHandle, + sliver: SliverPinnedHeader( + child: _buildActions(connectedState.isConnected), ), - ], - body: BlocBuilder>( - builder: (context, state) { - if (state.isEmpty) { - return SizedBox.expand( - child: Center( - child: _buildEmptyState( - connectedState.isConnected, - state, - ), - ), - ); - } else { - return _buildImageGrid(state); - } - }, ), + ], + body: BlocBuilder>( + builder: (context, state) { + if (state.isEmpty) { + return SizedBox.expand( + child: Center( + child: _buildEmptyState( + connectedState.isConnected, + state, + ), + ), + ); + } else { + return _buildImageGrid(state); + } + }, ), ), - ); - }, - ), + ), + ); + }, ); }, ); @@ -260,11 +256,10 @@ class _ScannerPageState extends State .getValue()! .enforceSinglePagePdfUpload, ); - final uploadResult = await pushDocumentUploadPreparationPage( - context, - bytes: file.bytes, + final uploadResult = await DocumentUploadRoute( + $extra: file.bytes, fileExtension: file.extension, - ); + ).push(context); if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) { // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); @@ -366,13 +361,12 @@ class _ScannerPageState extends State ); return; } - pushDocumentUploadPreparationPage( - context, - bytes: file.readAsBytesSync(), + DocumentUploadRoute( + $extra: file.readAsBytesSync(), filename: fileDescription.filename, title: fileDescription.filename, fileExtension: fileDescription.extension, - ); + ).push(context); } } diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index 08de60e6..6ed9eca6 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -1,18 +1,12 @@ import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/user_repository.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; -import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -85,24 +79,14 @@ class _DocumentSearchBarState extends State { ); }, openBuilder: (_, action) { - return MultiProvider( - providers: [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - if (context.watch().hasMultiUserSupport) - Provider.value(value: context.read()), - ], - child: Provider( - create: (_) => DocumentSearchCubit( - context.read(), - context.read(), - Hive.box(HiveBoxes.localUserAppState) - .get(context.watch().id)!, - ), - builder: (_, __) => const DocumentSearchPage(), + return Provider( + create: (_) => DocumentSearchCubit( + context.read(), + context.read(), + Hive.box(HiveBoxes.localUserAppState) + .get(context.read().id)!, ), + child: const DocumentSearchPage(), ); }, ), @@ -114,11 +98,10 @@ class _DocumentSearchBarState extends State { padding: const EdgeInsets.all(6), icon: UserAvatar(account: context.watch()), onPressed: () { - final apiVersion = context.read(); showDialog( context: context, - builder: (context) => Provider.value( - value: apiVersion, + builder: (_) => Provider.value( + value: context.read(), child: const ManageAccountsPage(), ), ); diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 9f8610ec..4dcf6659 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -56,11 +56,10 @@ class SliverSearchBar extends StatelessWidget { }, ), onPressed: () { - final apiVersion = context.read(); showDialog( context: context, - builder: (context) => Provider.value( - value: apiVersion, + builder: (_) => Provider.value( + value: context.read(), child: const ManageAccountsPage(), ), ); diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 89903876..0503da4f 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -3,8 +3,9 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; - +import 'package:image/image.dart' as img; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -56,7 +57,7 @@ class _DocumentUploadPreparationPageState static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss"); final GlobalKey _formKey = GlobalKey(); - + Color? _titleColor; Map _errors = {}; bool _isUploadLoading = false; late bool _syncTitleAndFilename; @@ -67,24 +68,21 @@ class _DocumentUploadPreparationPageState void initState() { super.initState(); _syncTitleAndFilename = widget.filename == null && widget.title == null; + _titleColor = _computeAverageColor().computeLuminance() > 0.5 + ? Colors.black + : Colors.white; initializeDateFormatting(); } @override Widget build(BuildContext context) { return Scaffold( + extendBodyBehindAppBar: false, resizeToAvoidBottomInset: true, - appBar: AppBar( - title: Text(S.of(context)!.prepareDocument), - bottom: _isUploadLoading - ? const PreferredSize( - child: LinearProgressIndicator(), - preferredSize: Size.fromHeight(4.0)) - : null, - ), floatingActionButton: Visibility( visible: MediaQuery.of(context).viewInsets.bottom == 0, child: FloatingActionButton.extended( + heroTag: "fab_document_upload", onPressed: _onSubmit, label: Text(S.of(context)!.upload), icon: const Icon(Icons.upload), @@ -94,183 +92,246 @@ class _DocumentUploadPreparationPageState builder: (context, state) { return FormBuilder( key: _formKey, - child: ListView( - children: [ - // Title - FormBuilderTextField( - autovalidateMode: AutovalidateMode.always, - name: DocumentModel.titleKey, - initialValue: - widget.title ?? "scan_${fileNameDateFormat.format(_now)}", - validator: (value) { - if (value?.trim().isEmpty ?? true) { - return S.of(context)!.thisFieldIsRequired; - } - return null; - }, - decoration: InputDecoration( - labelText: S.of(context)!.title, - suffixIcon: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _formKey.currentState?.fields[DocumentModel.titleKey] - ?.didChange(""); - if (_syncTitleAndFilename) { - _formKey.currentState?.fields[fkFileName] - ?.didChange(""); - } - }, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: + NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: SliverAppBar( + leading: BackButton( + color: _titleColor, ), - errorText: _errors[DocumentModel.titleKey], - ), - onChanged: (value) { - final String transformedValue = - _formatFilename(value ?? ''); - if (_syncTitleAndFilename) { - _formKey.currentState?.fields[fkFileName] - ?.didChange(transformedValue); - } - }, - ), - // Filename - FormBuilderTextField( - autovalidateMode: AutovalidateMode.always, - readOnly: _syncTitleAndFilename, - enabled: !_syncTitleAndFilename, - name: fkFileName, - decoration: InputDecoration( - labelText: S.of(context)!.fileName, - suffixText: widget.fileExtension, - suffixIcon: IconButton( - icon: const Icon(Icons.clear), - onPressed: () => _formKey.currentState?.fields[fkFileName] - ?.didChange(''), + pinned: true, + expandedHeight: 150, + flexibleSpace: FlexibleSpaceBar( + background: Image.memory( + widget.fileBytes, + fit: BoxFit.cover, + ), + title: Text( + S.of(context)!.prepareDocument, + style: TextStyle( + color: _titleColor, + ), + ), ), - ), - initialValue: widget.filename ?? - "scan_${fileNameDateFormat.format(_now)}", - ), - // Synchronize title and filename - SwitchListTile( - value: _syncTitleAndFilename, - onChanged: (value) { - setState( - () => _syncTitleAndFilename = value, - ); - if (_syncTitleAndFilename) { - final String transformedValue = _formatFilename(_formKey - .currentState - ?.fields[DocumentModel.titleKey] - ?.value as String); - if (_syncTitleAndFilename) { - _formKey.currentState?.fields[fkFileName] - ?.didChange(transformedValue); - } - } - }, - title: Text( - S.of(context)!.synchronizeTitleAndFilename, - ), - ), - // Created at - FormBuilderDateTimePicker( - autovalidateMode: AutovalidateMode.always, - format: DateFormat.yMMMMd(), - inputType: InputType.date, - name: DocumentModel.createdKey, - initialValue: null, - onChanged: (value) { - setState(() => _showDatePickerDeleteIcon = value != null); - }, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.calendar_month_outlined), - labelText: S.of(context)!.createdAt + " *", - suffixIcon: _showDatePickerDeleteIcon - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _formKey.currentState! - .fields[DocumentModel.createdKey] - ?.didChange(null); - }, + bottom: _isUploadLoading + ? const PreferredSize( + child: LinearProgressIndicator(), + preferredSize: Size.fromHeight(4.0), ) : null, ), ), - // Correspondent - if (context - .watch() - .paperlessUser - .canViewCorrespondents) - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialName) => MultiProvider( - providers: [ - Provider.value( - value: context.read(), + ], + body: Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: + NestedScrollView.sliverOverlapAbsorberHandleFor( + context), ), - Provider.value( - value: context.read(), - ) - ], - child: AddCorrespondentPage(initialName: initialName), - ), - addLabelText: S.of(context)!.addCorrespondent, - labelText: S.of(context)!.correspondent + " *", - name: DocumentModel.correspondentKey, - options: state.correspondents, - prefixIcon: const Icon(Icons.person_outline), - allowSelectUnassigned: true, - canCreateNewLabel: context - .watch() - .paperlessUser - .canCreateCorrespondents, - ), - // Document type - if (context - .watch() - .paperlessUser - .canViewDocumentTypes) - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialName) => MultiProvider( - providers: [ - Provider.value( - value: context.read(), + SliverList.list( + children: [ + // Title + FormBuilderTextField( + autovalidateMode: AutovalidateMode.always, + name: DocumentModel.titleKey, + initialValue: widget.title ?? + "scan_${fileNameDateFormat.format(_now)}", + validator: (value) { + if (value?.trim().isEmpty ?? true) { + return S.of(context)!.thisFieldIsRequired; + } + return null; + }, + decoration: InputDecoration( + labelText: S.of(context)!.title, + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _formKey.currentState + ?.fields[DocumentModel.titleKey] + ?.didChange(""); + if (_syncTitleAndFilename) { + _formKey.currentState?.fields[fkFileName] + ?.didChange(""); + } + }, + ), + errorText: _errors[DocumentModel.titleKey], + ), + onChanged: (value) { + final String transformedValue = + _formatFilename(value ?? ''); + if (_syncTitleAndFilename) { + _formKey.currentState?.fields[fkFileName] + ?.didChange(transformedValue); + } + }, + ), + // Filename + FormBuilderTextField( + autovalidateMode: AutovalidateMode.always, + readOnly: _syncTitleAndFilename, + enabled: !_syncTitleAndFilename, + name: fkFileName, + decoration: InputDecoration( + labelText: S.of(context)!.fileName, + suffixText: widget.fileExtension, + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _formKey + .currentState?.fields[fkFileName] + ?.didChange(''), + ), + ), + initialValue: widget.filename ?? + "scan_${fileNameDateFormat.format(_now)}", + ), + // Synchronize title and filename + SwitchListTile( + value: _syncTitleAndFilename, + onChanged: (value) { + setState( + () => _syncTitleAndFilename = value, + ); + if (_syncTitleAndFilename) { + final String transformedValue = + _formatFilename(_formKey + .currentState + ?.fields[DocumentModel.titleKey] + ?.value as String); + if (_syncTitleAndFilename) { + _formKey.currentState?.fields[fkFileName] + ?.didChange(transformedValue); + } + } + }, + title: Text( + S.of(context)!.synchronizeTitleAndFilename, + ), + ), + // Created at + FormBuilderDateTimePicker( + autovalidateMode: AutovalidateMode.always, + format: DateFormat.yMMMMd(), + inputType: InputType.date, + name: DocumentModel.createdKey, + initialValue: null, + onChanged: (value) { + setState(() => + _showDatePickerDeleteIcon = value != null); + }, + decoration: InputDecoration( + prefixIcon: + const Icon(Icons.calendar_month_outlined), + labelText: S.of(context)!.createdAt + " *", + suffixIcon: _showDatePickerDeleteIcon + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _formKey.currentState! + .fields[DocumentModel.createdKey] + ?.didChange(null); + }, + ) + : null, + ), + ), + // Correspondent + if (context + .watch() + .paperlessUser + .canViewCorrespondents) + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialName) => + MultiProvider( + providers: [ + Provider.value( + value: context.read(), + ), + Provider.value( + value: context.read(), + ) + ], + child: AddCorrespondentPage( + initialName: initialName), + ), + addLabelText: S.of(context)!.addCorrespondent, + labelText: S.of(context)!.correspondent + " *", + name: DocumentModel.correspondentKey, + options: state.correspondents, + prefixIcon: const Icon(Icons.person_outline), + allowSelectUnassigned: true, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateCorrespondents, + ), + // Document type + if (context + .watch() + .paperlessUser + .canViewDocumentTypes) + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialName) => + MultiProvider( + providers: [ + Provider.value( + value: context.read(), + ), + Provider.value( + value: context.read(), + ) + ], + child: AddDocumentTypePage( + initialName: initialName), + ), + addLabelText: S.of(context)!.addDocumentType, + labelText: S.of(context)!.documentType + " *", + name: DocumentModel.documentTypeKey, + options: state.documentTypes, + prefixIcon: + const Icon(Icons.description_outlined), + allowSelectUnassigned: true, + canCreateNewLabel: context + .watch() + .paperlessUser + .canCreateDocumentTypes, + ), + if (context + .watch() + .paperlessUser + .canViewTags) + TagsFormField( + name: DocumentModel.tagsKey, + allowCreation: true, + allowExclude: false, + allowOnlySelection: true, + options: state.tags, + ), + Text( + "* " + S.of(context)!.uploadInferValuesHint, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.justify, + ).padded(), + const SizedBox(height: 300), + ].padded(), ), - Provider.value( - value: context.read(), - ) ], - child: AddDocumentTypePage(initialName: initialName), - ), - addLabelText: S.of(context)!.addDocumentType, - labelText: S.of(context)!.documentType + " *", - name: DocumentModel.documentTypeKey, - options: state.documentTypes, - prefixIcon: const Icon(Icons.description_outlined), - allowSelectUnassigned: true, - canCreateNewLabel: context - .watch() - .paperlessUser - .canCreateDocumentTypes, - ), - if (context.watch().paperlessUser.canViewTags) - TagsFormField( - name: DocumentModel.tagsKey, - allowCreation: true, - allowExclude: false, - allowOnlySelection: true, - options: state.tags, - ), - Text( - "* " + S.of(context)!.uploadInferValuesHint, - style: Theme.of(context).textTheme.bodySmall, + ); + }, ), - const SizedBox(height: 300), - ].padded(), + ), ), ); }, @@ -317,10 +378,7 @@ class _DocumentUploadPreparationPageState context, S.of(context)!.documentSuccessfullyUploadedProcessing, ); - Navigator.pop( - context, - DocumentUploadResult(true, taskId), - ); + context.pop(DocumentUploadResult(true, taskId)); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } on PaperlessFormValidationException catch (exception) { @@ -345,4 +403,33 @@ class _DocumentUploadPreparationPageState String _formatFilename(String source) { return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase(); } + + Color _computeAverageColor() { + final bitmap = img.decodeImage(widget.fileBytes); + if (bitmap == null) { + return Colors.black; + } + int redBucket = 0; + int greenBucket = 0; + int blueBucket = 0; + int pixelCount = 0; + + for (int y = 0; y < bitmap.height; y++) { + for (int x = 0; x < bitmap.width; x++) { + final c = bitmap.getPixel(x, y); + + pixelCount++; + redBucket += c.r.toInt(); + greenBucket += c.g.toInt(); + blueBucket += c.b.toInt(); + } + } + + return Color.fromRGBO( + redBucket ~/ pixelCount, + greenBucket ~/ pixelCount, + blueBucket ~/ pixelCount, + 1, + ); + } } diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 54444f05..f06cdc82 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -134,7 +134,7 @@ class _DocumentsPageState extends State Padding( padding: const EdgeInsets.all(8.0), child: FloatingActionButton.small( - key: UniqueKey(), + heroTag: "fab_documents_page_reset_filter", backgroundColor: Theme.of(context) .colorScheme .onPrimaryContainer, @@ -164,11 +164,13 @@ class _DocumentsPageState extends State duration: const Duration(milliseconds: 250), child: (_currentTab == 0) ? FloatingActionButton( + heroTag: "fab_documents_page_filter", child: const Icon(Icons.filter_alt_outlined), onPressed: _openDocumentFilter, ) : FloatingActionButton( + heroTag: "fab_documents_page_filter", child: const Icon(Icons.add), onPressed: () => _onCreateSavedView(state.filter), diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 74da008e..576d04d7 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -12,6 +13,7 @@ class DocumentPreview extends StatelessWidget { final double borderRadius; final bool enableHero; final double scale; + final bool isClickable; const DocumentPreview({ super.key, @@ -21,15 +23,23 @@ class DocumentPreview extends StatelessWidget { this.borderRadius = 12.0, this.enableHero = true, this.scale = 1.1, + this.isClickable = true, }); @override Widget build(BuildContext context) { - return HeroMode( - enabled: enableHero, - child: Hero( - tag: "thumb_${document.id}", - child: _buildPreview(context), + return GestureDetector( + onTap: isClickable + ? () { + DocumentPreviewRoute($extra: document).push(context); + } + : null, + child: HeroMode( + enabled: enableHero, + child: Hero( + tag: "thumb_${document.id}", + child: _buildPreview(context), + ), ), ); } diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index e255b6db..8cdb5afc 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -80,6 +80,7 @@ class _DocumentFilterPanelState extends State { floatingActionButton: Visibility( visible: MediaQuery.of(context).viewInsets.bottom == 0, child: FloatingActionButton.extended( + heroTag: "fab_document_filter_panel", icon: const Icon(Icons.done), label: Text(S.of(context)!.apply), onPressed: _onApplyFilter, diff --git a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart index 76b47849..52874b91 100644 --- a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class DocumentSelectionSliverAppBar extends StatelessWidget { final DocumentsState state; @@ -65,24 +66,30 @@ class DocumentSelectionSliverAppBar extends StatelessWidget { label: Text(S.of(context)!.correspondent), avatar: const Icon(Icons.edit), onPressed: () { - pushBulkEditCorrespondentRoute(context, - selection: state.selection); + BulkEditDocumentsRoute(BulkEditExtraWrapper( + state.selection, + LabelType.correspondent, + )).push(context); }, ).paddedOnly(left: 8, right: 4), ActionChip( label: Text(S.of(context)!.documentType), avatar: const Icon(Icons.edit), onPressed: () async { - pushBulkEditDocumentTypeRoute(context, - selection: state.selection); + BulkEditDocumentsRoute(BulkEditExtraWrapper( + state.selection, + LabelType.documentType, + )).push(context); }, ).paddedOnly(left: 8, right: 4), ActionChip( label: Text(S.of(context)!.storagePath), avatar: const Icon(Icons.edit), onPressed: () async { - pushBulkEditStoragePathRoute(context, - selection: state.selection); + BulkEditDocumentsRoute(BulkEditExtraWrapper( + state.selection, + LabelType.storagePath, + )).push(context); }, ).paddedOnly(left: 8, right: 4), _buildBulkEditTagsChip(context).paddedOnly(left: 4, right: 4), @@ -98,7 +105,10 @@ class DocumentSelectionSliverAppBar extends StatelessWidget { label: Text(S.of(context)!.tags), avatar: const Icon(Icons.edit), onPressed: () { - pushBulkEditTagsRoute(context, selection: state.selection); + BulkEditDocumentsRoute(BulkEditExtraWrapper( + state.selection, + LabelType.tag, + )).push(context); }, ); } diff --git a/lib/features/edit_label/view/edit_label_page.dart b/lib/features/edit_label/view/edit_label_page.dart index 7ad57e40..9ed93695 100644 --- a/lib/features/edit_label/view/edit_label_page.dart +++ b/lib/features/edit_label/view/edit_label_page.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; @@ -119,11 +120,11 @@ class EditLabelForm extends StatelessWidget { } catch (error, stackTrace) { log("An error occurred!", error: error, stackTrace: stackTrace); } - Navigator.pop(context); + context.pop(); } } else { onDelete(context, label); - Navigator.pop(context); + context.pop(); } } } diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index d0ce3cec..d20c70bb 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; @@ -74,6 +75,7 @@ class _LabelFormState extends State> { return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_label_form", icon: widget.submitButtonConfig.icon, label: widget.submitButtonConfig.label, onPressed: _onSubmit, @@ -168,7 +170,7 @@ class _LabelFormState extends State> { }; final parsed = widget.fromJsonT(mergedJson); final createdLabel = await widget.submitButtonConfig.onSubmit(parsed); - Navigator.pop(context, createdLabel); + context.pop(createdLabel); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } on PaperlessFormValidationException catch (exception) { diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 69c8a95b..f0e07852 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -16,9 +17,14 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:provider/provider.dart'; class HomeShellWidget extends StatelessWidget { @@ -57,7 +63,10 @@ class HomeShellWidget extends StatelessWidget { .listenable(keys: [currentUserId]), builder: (context, box, _) { final currentLocalUser = box.get(currentUserId)!; + print(currentLocalUser.paperlessUser.canViewDocuments); + print(currentLocalUser.paperlessUser.canViewTags); return MultiProvider( + key: ValueKey(currentUserId), providers: [ Provider.value(value: currentLocalUser), Provider.value(value: apiVersion), @@ -162,16 +171,22 @@ class HomeShellWidget extends StatelessWidget { create: (context) => DocumentScannerCubit(context.read()), ), - if (currentLocalUser.paperlessUser.canViewDocuments && - currentLocalUser.paperlessUser.canViewTags) - Provider( - create: (context) => InboxCubit( + Provider( + create: (context) { + final inboxCubit = InboxCubit( context.read(), context.read(), context.read(), context.read(), - ).initialize(), - ), + ); + if (currentLocalUser + .paperlessUser.canViewDocuments && + currentLocalUser.paperlessUser.canViewTags) { + inboxCubit.initialize(); + } + return inboxCubit; + }, + ), Provider( create: (context) => SavedViewCubit( context.read(), diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index 518b8d3e..0d675991 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -46,24 +46,24 @@ class ScaffoldWithNavigationBarState extends State { if (widget.authenticatedUser.canViewDocuments) { widget.navigationShell.goBranch(index); } else { - showSnackBar( - context, "You do not have permission to access this page."); + showSnackBar(context, + "You do not have the required permissions to access this page."); } break; case _scannerIndex: if (widget.authenticatedUser.canCreateDocuments) { widget.navigationShell.goBranch(index); } else { - showSnackBar( - context, "You do not have permission to access this page."); + showSnackBar(context, + "You do not have the required permissions to access this page."); } break; case _labelsIndex: if (widget.authenticatedUser.canViewAnyLabel) { widget.navigationShell.goBranch(index); } else { - showSnackBar( - context, "You do not have permission to access this page."); + showSnackBar(context, + "You do not have the required permissions to access this page."); } break; case _inboxIndex: @@ -71,8 +71,8 @@ class ScaffoldWithNavigationBarState extends State { widget.authenticatedUser.canViewTags) { widget.navigationShell.goBranch(index); } else { - showSnackBar( - context, "You do not have permission to access this page."); + showSnackBar(context, + "You do not have the required permissions to access this page."); } break; default: @@ -132,7 +132,7 @@ class ScaffoldWithNavigationBarState extends State { if (!(widget.authenticatedUser.canViewDocuments && widget.authenticatedUser.canViewTags)) { return Icon( - Icons.close, + Icons.inbox_outlined, color: disabledColor, ); } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 5ee407d6..fecaf543 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -48,6 +48,7 @@ class _InboxPageState extends State 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 diff --git a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart index b583256e..37ef0022 100644 --- a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart +++ b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart @@ -72,6 +72,7 @@ class _FullscreenTagsFormState extends State { return Scaffold( floatingActionButton: widget.allowCreation ? FloatingActionButton( + heroTag: "fab_tags_form", onPressed: _onAddTag, child: const Icon(Icons.add), ) diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 5aa210b3..ff7b39c9 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -66,15 +66,19 @@ class _LabelsPageState extends State child: Scaffold( drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( + heroTag: "fab_labels_page", onPressed: [ if (user.canViewCorrespondents) - () => CreateLabelRoute().push(context), + () => CreateLabelRoute(LabelType.correspondent) + .push(context), if (user.canViewDocumentTypes) - () => CreateLabelRoute().push(context), + () => CreateLabelRoute(LabelType.documentType) + .push(context), if (user.canViewTags) - () => CreateLabelRoute().push(context), + () => CreateLabelRoute(LabelType.tag).push(context), if (user.canViewStoragePaths) - () => CreateLabelRoute().push(context), + () => CreateLabelRoute(LabelType.storagePath) + .push(context), ][_currentIndex], child: const Icon(Icons.add), ), @@ -247,7 +251,8 @@ class _LabelsPageState extends State }, emptyStateActionButtonLabel: S.of(context)!.addNewCorrespondent, emptyStateDescription: S.of(context)!.noCorrespondentsSetUp, - onAddNew: () => CreateLabelRoute().push(context), + onAddNew: () => + CreateLabelRoute(LabelType.correspondent).push(context), ), ], ); @@ -274,7 +279,8 @@ class _LabelsPageState extends State }, emptyStateActionButtonLabel: S.of(context)!.addNewDocumentType, emptyStateDescription: S.of(context)!.noDocumentTypesSetUp, - onAddNew: () => CreateLabelRoute().push(context), + onAddNew: () => + CreateLabelRoute(LabelType.documentType).push(context), ), ], ); @@ -310,7 +316,7 @@ class _LabelsPageState extends State ), emptyStateActionButtonLabel: S.of(context)!.addNewTag, emptyStateDescription: S.of(context)!.noTagsSetUp, - onAddNew: () => CreateLabelRoute().push(context), + onAddNew: () => CreateLabelRoute(LabelType.tag).push(context), ), ], ); @@ -338,7 +344,8 @@ class _LabelsPageState extends State contentBuilder: (path) => Text(path.path), emptyStateActionButtonLabel: S.of(context)!.addNewStoragePath, emptyStateDescription: S.of(context)!.noStoragePathsSetUp, - onAddNew: () => CreateLabelRoute().push(context), + onAddNew: () => + CreateLabelRoute(LabelType.storagePath).push(context), ), ], ); diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart index ed10064f..e3a047f1 100644 --- a/lib/features/labels/view/widgets/label_item.dart +++ b/lib/features/labels/view/widgets/label_item.dart @@ -4,6 +4,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; class LabelItem extends StatelessWidget { final T label; @@ -44,7 +45,7 @@ class LabelItem extends StatelessWidget { onPressed: canOpen ? () { final filter = filterBuilder(label); - pushLinkedDocumentsView(context, filter: filter); + LinkedDocumentsRoute(filter).push(context); } : null, ); diff --git a/lib/features/labels/view/widgets/label_text.dart b/lib/features/labels/view/widgets/label_text.dart index bf15b425..a1b3e1ff 100644 --- a/lib/features/labels/view/widgets/label_text.dart +++ b/lib/features/labels/view/widgets/label_text.dart @@ -8,7 +8,7 @@ class LabelText extends StatelessWidget { const LabelText({ super.key, this.style, - this.placeholder = "", + this.placeholder = "-", required this.label, }); diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index 18a2a9d3..ecca02b3 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -1,11 +1,7 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.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/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:sliver_tools/sliver_tools.dart'; class LandingPage extends StatefulWidget { const LandingPage({super.key}); diff --git a/lib/features/saved_view/view/add_saved_view_page.dart b/lib/features/saved_view/view/add_saved_view_page.dart index e0b6e887..e24ad874 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -39,6 +40,7 @@ class _AddSavedViewPageState extends State { title: Text(S.of(context)!.newView), ), floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_add_saved_view_page", icon: const Icon(Icons.add), onPressed: () => _onCreate(context), label: Text(S.of(context)!.create), @@ -102,8 +104,7 @@ class _AddSavedViewPageState extends State { void _onCreate(BuildContext context) { if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { - Navigator.pop( - context, + context.pop( SavedView.fromDocumentFilter( DocumentFilterForm.assembleFilter( _filterFormKey, diff --git a/lib/features/saved_view_details/view/saved_view_details_page.dart b/lib/features/saved_view_details/view/saved_view_details_page.dart index 352e7a23..a98634b5 100644 --- a/lib/features/saved_view_details/view/saved_view_details_page.dart +++ b/lib/features/saved_view_details/view/saved_view_details_page.dart @@ -47,7 +47,7 @@ class _SavedViewDetailsPageState extends State false; if (shouldDelete) { await widget.onDelete(cubit.savedView); - Navigator.pop(context); + context.pop(context); } }, ), diff --git a/lib/main.dart b/lib/main.dart index 2eebcf17..5c14e103 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'package:paperless_mobile/features/notifications/services/local_notificat import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -163,9 +164,7 @@ void main() async { BlocProvider.value(value: connectivityCubit), BlocProvider.value(value: authenticationCubit), ], - child: GoRouterShell( - apiFactory: apiFactory, - ), + child: GoRouterShell(apiFactory: apiFactory), ), ), ); @@ -178,65 +177,9 @@ void main() async { debugPrint("An unepxected exception has occured!"); debugPrint(message); debugPrintStack(stackTrace: stack); - // if (_rootScaffoldKey.currentContext != null) { - // ScaffoldMessenger.maybeOf(_rootScaffoldKey.currentContext!) - // ?..hideCurrentSnackBar() - // ..showSnackBar(SnackBar(content: Text(message))); - // } }); } -// class PaperlessMobileEntrypoint extends StatefulWidget { -// final PaperlessApiFactory paperlessProviderFactory; -// const PaperlessMobileEntrypoint({ -// Key? key, -// required this.paperlessProviderFactory, -// }) : super(key: key); - -// @override -// State createState() => -// _PaperlessMobileEntrypointState(); -// } - -// class _PaperlessMobileEntrypointState extends State { -// @override -// Widget build(BuildContext context) { -// return GlobalSettingsBuilder( -// builder: (context, settings) { -// return DynamicColorBuilder( -// builder: (lightDynamic, darkDynamic) { -// return MaterialApp( -// debugShowCheckedModeBanner: true, -// title: "Paperless Mobile", -// theme: buildTheme( -// brightness: Brightness.light, -// dynamicScheme: lightDynamic, -// preferredColorScheme: settings.preferredColorSchemeOption, -// ), -// darkTheme: buildTheme( -// brightness: Brightness.dark, -// dynamicScheme: darkDynamic, -// preferredColorScheme: settings.preferredColorSchemeOption, -// ), -// themeMode: settings.preferredThemeMode, -// supportedLocales: S.supportedLocales, -// locale: Locale.fromSubtags( -// languageCode: settings.preferredLocaleSubtag, -// ), -// localizationsDelegates: const [ -// ...S.localizationsDelegates, -// ], -// home: AuthenticationWrapper( -// paperlessProviderFactory: widget.paperlessProviderFactory, -// ), -// ); -// }, -// ); -// }, -// ); -// } -// } - class GoRouterShell extends StatefulWidget { final PaperlessApiFactory apiFactory; const GoRouterShell({ @@ -252,21 +195,19 @@ class _GoRouterShellState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - context.read().restoreSessionState().then((value) { - FlutterNativeSplash.remove(); - }); } @override void initState() { super.initState(); - // Activate the highest supported refresh rate on the device + FlutterNativeSplash.remove(); if (Platform.isAndroid) { _setOptimalDisplayMode(); } initializeDateFormatting(); } + /// Activates the highest supported refresh rate on the device. Future _setOptimalDisplayMode() async { final List supported = await FlutterDisplayMode.supported; final DisplayMode active = await FlutterDisplayMode.active; @@ -284,7 +225,7 @@ class _GoRouterShellState extends State { } late final _router = GoRouter( - debugLogDiagnostics: true, + debugLogDiagnostics: kDebugMode, initialLocation: "/login", routes: [ $loginRoute, @@ -348,23 +289,20 @@ class _GoRouterShellState extends State { @override Widget build(BuildContext context) { - return GlobalSettingsBuilder( - builder: (context, settings) { - return DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) { - return BlocListener( - listener: (context, state) { - state.when( - unauthenticated: () => const LoginRoute().go(context), - requriresLocalAuthentication: () => - const VerifyIdentityRoute().go(context), - authenticated: (localUserId) => - const LandingRoute().go(context), - switchingAccounts: () => - const SwitchingAccountsRoute().go(context), - ); - }, - child: MaterialApp.router( + return BlocListener( + listener: (context, state) { + state.when( + unauthenticated: () => _router.goNamed(R.login), + requriresLocalAuthentication: () => _router.goNamed(R.verifyIdentity), + switchingAccounts: () => _router.goNamed(R.switchingAccounts), + authenticated: (localUserId) => _router.goNamed(R.landing), + ); + }, + child: GlobalSettingsBuilder( + builder: (context, settings) { + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return MaterialApp.router( routerConfig: _router, debugShowCheckedModeBanner: true, title: "Paperless Mobile", @@ -384,79 +322,11 @@ class _GoRouterShellState extends State { languageCode: settings.preferredLocaleSubtag, ), localizationsDelegates: S.localizationsDelegates, - ), - ); - }, - ); - }, + ); + }, + ); + }, + ), ); } } - -// class AuthenticationWrapper extends StatefulWidget { -// final PaperlessApiFactory paperlessProviderFactory; - -// const AuthenticationWrapper({ -// Key? key, -// required this.paperlessProviderFactory, -// }) : super(key: key); - -// @override -// State createState() => _AuthenticationWrapperState(); -// } - -// class _AuthenticationWrapperState extends State { -// @override -// void didChangeDependencies() { -// super.didChangeDependencies(); -// context.read().restoreSessionState().then((value) { -// FlutterNativeSplash.remove(); -// }); -// } - -// @override -// void initState() { -// super.initState(); - -// // Activate the highest supported refresh rate on the device -// if (Platform.isAndroid) { -// _setOptimalDisplayMode(); -// } -// initializeDateFormatting(); -// } - -// Future _setOptimalDisplayMode() async { -// final List supported = await FlutterDisplayMode.supported; -// final DisplayMode active = await FlutterDisplayMode.active; - -// final List sameResolution = supported -// .where((m) => m.width == active.width && m.height == active.height) -// .toList() -// ..sort((a, b) => b.refreshRate.compareTo(a.refreshRate)); - -// final DisplayMode mostOptimalMode = -// sameResolution.isNotEmpty ? sameResolution.first : active; -// debugPrint('Setting refresh rate to ${mostOptimalMode.refreshRate}'); - -// await FlutterDisplayMode.setPreferredMode(mostOptimalMode); -// } - -// @override -// Widget build(BuildContext context) { -// return BlocBuilder( -// builder: (context, authentication) { -// return authentication.when( -// unauthenticated: () => const LoginPage(), -// requriresLocalAuthentication: () => const VerifyIdentityPage(), -// authenticated: (localUserId, apiVersion) => HomeShellWidget( -// key: ValueKey(localUserId), -// paperlessApiVersion: apiVersion, -// paperlessProviderFactory: widget.paperlessProviderFactory, -// localUserId: localUserId, -// ), -// switchingAccounts: () => const SwitchingAccountsPage(), -// ); -// }, -// ); -// } -// } diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 291ff434..308003a0 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -17,4 +17,6 @@ class R { static const inbox = "inbox"; static const documentPreview = "documentPreview"; static const settings = "settings"; + static const linkedDocuments = "linkedDocuments"; + static const bulkEditDocuments = "bulkEditDocuments"; } diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index 61748381..556f9423 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -1,17 +1,17 @@ -import 'dart:ffi'; -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:flutter/widgets.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; +import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart'; +import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; @@ -37,7 +37,11 @@ class DocumentsBranch extends StatefulShellBranchData { TypedGoRoute( path: "preview", name: R.documentPreview, - ) + ), + TypedGoRoute( + path: "bulk-edit", + name: R.bulkEditDocuments, + ), ], ) class DocumentsRoute extends GoRouteData { @@ -101,13 +105,115 @@ class EditDocumentRoute extends GoRouteData { } class DocumentPreviewRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + final DocumentModel $extra; - const DocumentPreviewRoute(this.$extra); + final String? title; + + const DocumentPreviewRoute({ + required this.$extra, + this.title, + }); @override Widget build(BuildContext context, GoRouterState state) { return DocumentView( documentBytes: context.read().download($extra), + title: title ?? $extra.title, + ); + } +} + +class BulkEditExtraWrapper { + final List selection; + final LabelType type; + + const BulkEditExtraWrapper(this.selection, this.type); +} + +class BulkEditDocumentsRoute extends GoRouteData { + /// Selection + final BulkEditExtraWrapper $extra; + BulkEditDocumentsRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (_) => DocumentBulkActionCubit( + context.read(), + context.read(), + context.read(), + selection: $extra.selection, + ), + child: BlocBuilder( + builder: (context, state) { + return switch ($extra.type) { + LabelType.tag => const FullscreenBulkEditTagsWidget(), + _ => FullscreenBulkEditLabelPage( + options: switch ($extra.type) { + LabelType.correspondent => state.correspondents, + LabelType.documentType => state.documentTypes, + LabelType.storagePath => state.storagePaths, + _ => throw Exception("Parameter not allowed here."), + }, + selection: state.selection, + labelMapper: (document) { + return switch ($extra.type) { + LabelType.correspondent => document.correspondent, + LabelType.documentType => document.documentType, + LabelType.storagePath => document.storagePath, + _ => throw Exception("Parameter not allowed here."), + }; + }, + leadingIcon: switch ($extra.type) { + LabelType.correspondent => const Icon(Icons.person_outline), + LabelType.documentType => + const Icon(Icons.description_outlined), + LabelType.storagePath => const Icon(Icons.folder_outlined), + _ => throw Exception("Parameter not allowed here."), + }, + hintText: S.of(context)!.startTyping, + onSubmit: switch ($extra.type) { + LabelType.correspondent => context + .read() + .bulkModifyCorrespondent, + LabelType.documentType => context + .read() + .bulkModifyDocumentType, + LabelType.storagePath => context + .read() + .bulkModifyStoragePath, + _ => throw Exception("Parameter not allowed here."), + }, + assignMessageBuilder: (int count, String name) { + return switch ($extra.type) { + LabelType.correspondent => S + .of(context)! + .bulkEditCorrespondentAssignMessage(name, count), + LabelType.documentType => S + .of(context)! + .bulkEditDocumentTypeAssignMessage(count, name), + LabelType.storagePath => S + .of(context)! + .bulkEditDocumentTypeAssignMessage(count, name), + _ => throw Exception("Parameter not allowed here."), + }; + }, + removeMessageBuilder: (int count) { + return switch ($extra.type) { + LabelType.correspondent => + S.of(context)!.bulkEditCorrespondentRemoveMessage(count), + LabelType.documentType => + S.of(context)!.bulkEditDocumentTypeRemoveMessage(count), + LabelType.storagePath => + S.of(context)!.bulkEditStoragePathRemoveMessage(count), + _ => throw Exception("Parameter not allowed here."), + }; + }, + ), + }; + }, + ), ); } } diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart index e1deac84..c70fc5d7 100644 --- a/lib/routes/typed/branches/labels_route.dart +++ b/lib/routes/typed/branches/labels_route.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; @@ -10,6 +11,8 @@ import 'package:paperless_mobile/features/edit_label/view/impl/edit_document_typ import 'package:paperless_mobile/features/edit_label/view/impl/edit_storage_path_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/edit_tag_page.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; +import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; +import 'package:paperless_mobile/features/linked_documents/view/linked_documents_page.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; @@ -32,6 +35,10 @@ class LabelsBranch extends StatefulShellBranchData { path: "create", name: R.createLabel, ), + TypedGoRoute( + path: "linked-documents", + name: R.linkedDocuments, + ), ], ) class LabelsRoute extends GoRouteData { @@ -59,26 +66,42 @@ class EditLabelRoute extends GoRouteData { } } -class CreateLabelRoute extends GoRouteData { +class CreateLabelRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - + final LabelType $extra; final String? name; - CreateLabelRoute({ + CreateLabelRoute( + this.$extra, { this.name, }); @override Widget build(BuildContext context, GoRouterState state) { - if (T is Correspondent) { - return AddCorrespondentPage(initialName: name); - } else if (T is DocumentType) { - return AddDocumentTypePage(initialName: name); - } else if (T is Tag) { - return AddTagPage(initialName: name); - } else if (T is StoragePath) { - return AddStoragePathPage(initialName: name); - } - throw ArgumentError(); + return switch ($extra) { + LabelType.correspondent => AddCorrespondentPage(initialName: name), + LabelType.documentType => AddDocumentTypePage(initialName: name), + LabelType.tag => AddTagPage(initialName: name), + LabelType.storagePath => AddStoragePathPage(initialName: name), + }; + } +} + +class LinkedDocumentsRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + final DocumentFilter $extra; + + const LinkedDocumentsRoute(this.$extra); + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (context) => LinkedDocumentsCubit( + $extra, + context.read(), + context.read(), + context.read(), + ), + child: const LinkedDocumentsPage(), + ); } } diff --git a/packages/paperless_api/lib/src/models/labels/label_model.dart b/packages/paperless_api/lib/src/models/labels/label_model.dart index 59706964..ab4c49e5 100644 --- a/packages/paperless_api/lib/src/models/labels/label_model.dart +++ b/packages/paperless_api/lib/src/models/labels/label_model.dart @@ -8,6 +8,13 @@ import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; part 'label_model.g.dart'; +enum LabelType { + correspondent, + documentType, + tag, + storagePath, +} + sealed class Label extends Equatable implements Comparable { static const idKey = "id"; static const nameKey = "name"; diff --git a/packages/paperless_document_scanner/example/lib/scan.dart b/packages/paperless_document_scanner/example/lib/scan.dart index aed8a266..a3bf9a18 100644 --- a/packages/paperless_document_scanner/example/lib/scan.dart +++ b/packages/paperless_document_scanner/example/lib/scan.dart @@ -124,19 +124,22 @@ class _ScanState extends State { imagePath = filePath; }); - EdgeDetectionResult result = await EdgeDetector().detectEdgesFromFile(filePath); + EdgeDetectionResult result = + await EdgeDetector().detectEdgesFromFile(filePath); setState(() { edgeDetectionResult = result; }); } - Future _processImage(String filePath, EdgeDetectionResult edgeDetectionResult) async { + Future _processImage( + String filePath, EdgeDetectionResult edgeDetectionResult) async { if (!mounted) { return; } - bool result = await EdgeDetector().processImageFromFile(filePath, edgeDetectionResult); + bool result = await EdgeDetector() + .processImageFromFile(filePath, edgeDetectionResult); if (result == false) { return; From 53a01ae775f0dad61b7b14dff905295b86ef245e Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 1 Aug 2023 18:09:14 +0200 Subject: [PATCH 03/12] feat: Add statistics card on landing page --- crowdin.yml | 1 + .../view/pages/document_details_page.dart | 9 +- .../view/scaffold_with_navigation_bar.dart | 13 +- lib/features/inbox/cubit/inbox_cubit.dart | 1 - lib/features/inbox/view/pages/inbox_page.dart | 4 + lib/features/landing/view/landing_page.dart | 135 +++++++++++++- .../landing/view/widgets/expansion_card.dart | 33 ++++ .../view/widgets/mime_types_pie_chart.dart | 166 ++++++++++++++++++ lib/l10n/intl_ca.arb | 8 +- lib/l10n/intl_cs.arb | 58 +++--- lib/l10n/intl_de.arb | 6 +- lib/l10n/intl_en.arb | 6 +- lib/l10n/intl_fr.arb | 30 ++-- lib/l10n/intl_pl.arb | 6 +- lib/l10n/intl_ru.arb | 10 +- lib/l10n/intl_tr.arb | 6 +- lib/main.dart | 5 +- lib/theme.dart | 7 + .../paperless_server_statistics_model.dart | 25 ++- pubspec.lock | 16 ++ pubspec.yaml | 2 + 21 files changed, 469 insertions(+), 78 deletions(-) create mode 100644 lib/features/landing/view/widgets/expansion_card.dart create mode 100644 lib/features/landing/view/widgets/mime_types_pie_chart.dart diff --git a/crowdin.yml b/crowdin.yml index d086c082..51718afb 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,4 @@ +project_id: "568557" files: [ { "source" : "/lib/l10n/intl_en.arb", 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 4f4249e3..97b8c7be 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -341,13 +341,6 @@ class _DocumentDetailsPageState extends State { document: state.document, enabled: isConnected, ), - // //TODO: Enable again, need new pdf viewer package... - // IconButton( - // tooltip: S.of(context)!.previewTooltip, - // icon: const Icon(Icons.visibility), - // onPressed: - // (isConnected) ? () => _onOpen(state.document) : null, - // ).paddedOnly(right: 4.0), IconButton( tooltip: S.of(context)!.openInSystemViewer, icon: const Icon(Icons.open_in_new), @@ -355,7 +348,7 @@ class _DocumentDetailsPageState extends State { ).paddedOnly(right: 4.0), DocumentShareButton(document: state.document), IconButton( - tooltip: S.of(context)!.print, //TODO: INTL + tooltip: S.of(context)!.print, onPressed: () => context.read().printDocument(), icon: const Icon(Icons.print), diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index 0d675991..fab2344e 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -46,24 +45,21 @@ class ScaffoldWithNavigationBarState extends State { if (widget.authenticatedUser.canViewDocuments) { widget.navigationShell.goBranch(index); } else { - showSnackBar(context, - "You do not have the required permissions to access this page."); + showSnackBar(context, S.of(context)!.missingPermissions); } break; case _scannerIndex: if (widget.authenticatedUser.canCreateDocuments) { widget.navigationShell.goBranch(index); } else { - showSnackBar(context, - "You do not have the required permissions to access this page."); + showSnackBar(context, S.of(context)!.missingPermissions); } break; case _labelsIndex: if (widget.authenticatedUser.canViewAnyLabel) { widget.navigationShell.goBranch(index); } else { - showSnackBar(context, - "You do not have the required permissions to access this page."); + showSnackBar(context, S.of(context)!.missingPermissions); } break; case _inboxIndex: @@ -71,8 +67,7 @@ class ScaffoldWithNavigationBarState extends State { widget.authenticatedUser.canViewTags) { widget.navigationShell.goBranch(index); } else { - showSnackBar(context, - "You do not have the required permissions to access this page."); + showSnackBar(context, S.of(context)!.missingPermissions); } break; default: diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index ffee09c4..3356fd53 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -61,7 +61,6 @@ class InboxCubit extends HydratedCubit Future initialize() async { await refreshItemsInInboxCount(false); await loadInbox(); - super.initialize(); } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index fecaf543..c1638f2a 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -214,6 +214,10 @@ class _InboxPageState extends State } 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( diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index ecca02b3..01a70274 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -1,7 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.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/landing/view/widgets/expansion_card.dart'; +import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/routes.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; +import 'package:fl_chart/fl_chart.dart'; class LandingPage extends StatefulWidget { const LandingPage({super.key}); @@ -14,6 +25,7 @@ class _LandingPageState extends State { final _searchBarHandle = SliverOverlapAbsorberHandle(); @override Widget build(BuildContext context) { + final currentUser = context.watch().paperlessUser; return SafeArea( child: Scaffold( drawer: const AppDrawer(), @@ -29,19 +41,126 @@ class _LandingPageState extends State { ], body: CustomScrollView( slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverToBoxAdapter( - child: Text( - "Welcome!", - style: Theme.of(context).textTheme.titleLarge, - ), - ), + SliverToBoxAdapter( + child: Text( + "Welcome to Paperless Mobile, ${currentUser.fullName ?? currentUser.username}!", + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith(fontSize: 28), + ).padded(24), ), + SliverToBoxAdapter(child: _buildStatisticsCard(context)), ], ), ), ), ); } + + Widget _buildStatisticsCard(BuildContext context) { + return FutureBuilder( + future: context.read().getServerStatistics(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Card( + margin: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Statistics", //TODO: INTL + style: Theme.of(context).textTheme.titleLarge, + ), + const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ), + ], + ).padded(16), + ); + } + final stats = snapshot.data!; + return ExpansionCard( + title: Text( + "Statistics", //TODO: INTL + style: Theme.of(context).textTheme.titleLarge, + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text("Documents in inbox:"), + onTap: () { + InboxRoute().go(context); + }, + trailing: Chip( + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + labelPadding: EdgeInsets.symmetric(horizontal: 4), + label: Text( + stats.documentsInInbox.toString(), + ), + ), + ), + ), + Card( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text("Total documents:"), + onTap: () { + DocumentsRoute().go(context); + }, + trailing: Chip( + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + labelPadding: EdgeInsets.symmetric(horizontal: 4), + label: Text( + stats.documentsTotal.toString(), + ), + ), + ), + ), + Card( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text("Total characters:"), + trailing: Chip( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + labelPadding: EdgeInsets.symmetric(horizontal: 4), + label: Text( + stats.totalChars.toString(), + ), + ), + ), + ), + AspectRatio( + aspectRatio: 1.3, + child: SizedBox( + width: 300, + child: MimeTypesPieChart(statistics: stats), + ), + ), + ], + ).padded(16), + ); + }, + ); + } } diff --git a/lib/features/landing/view/widgets/expansion_card.dart b/lib/features/landing/view/widgets/expansion_card.dart new file mode 100644 index 00000000..15bb0060 --- /dev/null +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; + +class ExpansionCard extends StatelessWidget { + final Widget title; + final Widget content; + + const ExpansionCard({super.key, required this.title, required this.content}); + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.all(16), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + expansionTileTheme: ExpansionTileThemeData( + shape: Theme.of(context).cardTheme.shape, + collapsedShape: Theme.of(context).cardTheme.shape, + ), + listTileTheme: ListTileThemeData( + shape: Theme.of(context).cardTheme.shape, + ), + ), + child: ExpansionTile( + initiallyExpanded: true, + title: title, + children: [content], + ), + ), + ); + } +} diff --git a/lib/features/landing/view/widgets/mime_types_pie_chart.dart b/lib/features/landing/view/widgets/mime_types_pie_chart.dart new file mode 100644 index 00000000..81749b5a --- /dev/null +++ b/lib/features/landing/view/widgets/mime_types_pie_chart.dart @@ -0,0 +1,166 @@ +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; + +class MimeTypesPieChart extends StatefulWidget { + final PaperlessServerStatisticsModel statistics; + + const MimeTypesPieChart({ + super.key, + required this.statistics, + }); + + @override + State createState() => _MimeTypesPieChartState(); +} + +class _MimeTypesPieChartState extends State { + static final _mimeTypeNames = { + "application/pdf": "PDF Document", + "image/png": "PNG Image", + "image/jpeg": "JPEG Image", + "image/tiff": "TIFF Image", + "image/gif": "GIF Image", + "image/webp": "WebP Image", + "text/plain": "Plain Text Document", + "application/msword": "Microsoft Word Document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + "Microsoft Word Document (OpenXML)", + "application/vnd.ms-powerpoint": "Microsoft PowerPoint Presentation", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": + "Microsoft PowerPoint Presentation (OpenXML)", + "application/vnd.ms-excel": "Microsoft Excel Spreadsheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + "Microsoft Excel Spreadsheet (OpenXML)", + "application/vnd.oasis.opendocument.text": "ODT Document", + "application/vnd.oasis.opendocument.presentation": "ODP Presentation", + "application/vnd.oasis.opendocument.spreadsheet": "ODS Spreadsheet", + }; + + int? _touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final colorShades = Colors.lightGreen.values; + + return Column( + children: [ + Expanded( + child: PieChart( + PieChartData( + startDegreeOffset: 90, + // pieTouchData: PieTouchData( + // touchCallback: (event, response) { + // setState(() { + // if (!event.isInterestedForInteractions || + // response == null || + // response.touchedSection == null) { + // _touchedIndex = -1; + // return; + // } + // _touchedIndex = + // response.touchedSection!.touchedSectionIndex; + // }); + // }, + // ), + borderData: FlBorderData( + show: false, + ), + sectionsSpace: 0, + centerSpaceRadius: 40, + sections: _buildSections(colorShades).toList(), + ), + ), + ), + Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < widget.statistics.fileTypeCounts.length; i++) + GestureDetector( + onTapDown: (_) { + setState(() { + _touchedIndex = i; + }); + }, + onTapUp: (details) { + setState(() { + _touchedIndex = -1; + }); + }, + onTapCancel: () { + setState(() { + _touchedIndex = -1; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorShades[i % colorShades.length], + ), + margin: EdgeInsets.only(right: 8), + width: 20, + height: 20, + ), + Text( + _mimeTypeNames[ + widget.statistics.fileTypeCounts[i].mimeType]!, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ], + ), + ], + ); + } + + Iterable _buildSections(List colorShades) sync* { + for (int i = 0; i < widget.statistics.fileTypeCounts.length; i++) { + final type = widget.statistics.fileTypeCounts[i]; + final isTouched = i == _touchedIndex; + final fontSize = isTouched ? 24.0 : 16.0; + final radius = isTouched ? 60.0 : 50.0; + const shadows = [ + Shadow(color: Colors.black, blurRadius: 2), + ]; + final color = colorShades[i % colorShades.length]; + final textColor = + color.computeLuminance() > 0.5 ? Colors.black : Colors.white; + yield PieChartSectionData( + color: colorShades[i % colorShades.length], + value: type.count.toDouble(), + title: ((type.count / widget.statistics.documentsTotal) * 100) + .toStringAsFixed(1) + + "%", + radius: radius, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: textColor, + ), + ); + } + } +} + +extension AllShades on MaterialColor { + List get values => [ + shade200, + shade600, + shade300, + shade100, + shade800, + shade400, + shade900, + shade500, + shade700, + ]; +} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index d38de266..21c9ec42 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Lliure {bytes}", + "freeBytes": "Lliure {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -857,8 +857,12 @@ "@convertSinglePageScanToPdf": { "description": "description of the upload scans as pdf setting" }, - "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", + "loginRequiredPermissionsHint": "L'ús de Paperless Mobile requereix un conjunt mínim de permisos d'usuari des de paperless-ngx 1.14.0 i posterior. Per tant, assegureu-vos que l'usuari que voleu iniciar sessió té el permís per veure altres usuaris (Usuari → Visualització) i la configuració (UISettings → Visualització). Si no teniu aquests permisos, poseu-vos en contacte amb un administrador del vostre servidor paperless-ngx.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 394defde..122eec31 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -81,7 +81,7 @@ "@createdAt": {}, "documentSuccessfullyDeleted": "Dokument byl úspěšně smazán.", "@documentSuccessfullyDeleted": {}, - "assignAsn": "Assign ASN", + "assignAsn": "Přiřadit ASČ", "@assignAsn": {}, "deleteDocumentTooltip": "Smazat", "@deleteDocumentTooltip": { @@ -129,13 +129,13 @@ }, "documentType": "Typ dokumentu", "@documentType": {}, - "archivedPdf": "Archived (pdf)", + "archivedPdf": "Archivováno (pdf)", "@archivedPdf": { "description": "Option to chose when downloading a document" }, "chooseFiletype": "Choose filetype", "@chooseFiletype": {}, - "original": "Original", + "original": "Originál", "@original": { "description": "Option to chose when downloading a document" }, @@ -580,7 +580,7 @@ "@done": {}, "next": "Další", "@next": {}, - "couldNotAccessReceivedFile": "Could not access the received file. Please try to open the app before sharing.", + "couldNotAccessReceivedFile": "Přístup k obdrženému souboru zamítnut. Než budeš sdílet, zkus nejdříve otevřít aplikaci.", "@couldNotAccessReceivedFile": {}, "newView": "Nový náhled", "@newView": {}, @@ -666,44 +666,44 @@ "@verifyYourIdentity": {}, "verifyIdentity": "Ověřit identitu", "@verifyIdentity": {}, - "detailed": "Detailed", + "detailed": "Detailně", "@detailed": {}, - "grid": "Grid", + "grid": "Mřížka", "@grid": {}, - "list": "List", + "list": "Seznam", "@list": {}, - "remove": "Remove", - "removeQueryFromSearchHistory": "Remove query from search history?", + "remove": "Odstranit", + "removeQueryFromSearchHistory": "Odstranit dotaz z historie vyhledávání?", "dynamicColorScheme": "Dynamicky", "@dynamicColorScheme": {}, "classicColorScheme": "Klasicky", "@classicColorScheme": {}, - "notificationDownloadComplete": "Download complete", + "notificationDownloadComplete": "Stahování dokončeno", "@notificationDownloadComplete": { "description": "Notification title when a download has been completed." }, - "notificationDownloadingDocument": "Downloading document", + "notificationDownloadingDocument": "Stahování dokumentu", "@notificationDownloadingDocument": { "description": "Notification title shown when a document download is pending" }, - "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "archiveSerialNumberUpdated": "Archivní sériové číslo aktualizováno.", "@archiveSerialNumberUpdated": { "description": "Message shown when the ASN has been updated." }, - "donateCoffee": "Buy me a coffee", + "donateCoffee": "Kupte mi kávu", "@donateCoffee": { "description": "Label displayed in the app drawer" }, - "thisFieldIsRequired": "This field is required!", + "thisFieldIsRequired": "Toto pole je povinné!", "@thisFieldIsRequired": { "description": "Message shown below the form field when a required field has not been filled out." }, - "confirm": "Confirm", - "confirmAction": "Confirm action", + "confirm": "Potvrdit", + "confirmAction": "Potvrdit akci", "@confirmAction": { "description": "Typically used as a title to confirm a previously selected action" }, - "areYouSureYouWantToContinue": "Are you sure you want to continue?", + "areYouSureYouWantToContinue": "Opravdu chcete pokračovat?", "bulkEditTagsAddMessage": "{count, plural, one{This operation will add the tags {tags} to the selected document.} other{This operation will add the tags {tags} to {count} selected documents.}}", "@bulkEditTagsAddMessage": { "description": "Message of the confirmation dialog when bulk adding tags." @@ -722,39 +722,39 @@ "bulkEditCorrespondentRemoveMessage": "{count, plural, one{This operation will remove the correspondent from the selected document.} other{This operation will remove the correspondent from {count} selected documents.}}", "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{This operation will remove the document type from the selected document.} other{This operation will remove the document type from {count} selected documents.}}", "bulkEditStoragePathRemoveMessage": "{count, plural, one{This operation will remove the storage path from the selected document.} other{This operation will remove the storage path from {count} selected documents.}}", - "anyTag": "Any", + "anyTag": "Jakékoliv", "@anyTag": { "description": "Label shown when any tag should be filtered" }, - "allTags": "All", + "allTags": "Všechny", "@allTags": { "description": "Label shown when a document has to be assigned to all selected tags" }, - "switchingAccountsPleaseWait": "Switching accounts. Please wait...", + "switchingAccountsPleaseWait": "Přepínání účtů. Počkejte prosím...", "@switchingAccountsPleaseWait": { "description": "Message shown while switching accounts is in progress." }, - "testConnection": "Test connection", + "testConnection": "Ověřit připojení", "@testConnection": { "description": "Button label shown on login page. Allows user to test whether the server is reachable or not." }, - "accounts": "Accounts", + "accounts": "Účty", "@accounts": { "description": "Title of the account management dialog" }, - "addAccount": "Add account", + "addAccount": "Přidat účet", "@addAccount": { "description": "Label of add account action" }, - "switchAccount": "Switch", + "switchAccount": "Přepnout", "@switchAccount": { "description": "Label for switch account action" }, - "logout": "Logout", + "logout": "Odhlásit", "@logout": { "description": "Generic Logout label" }, - "switchAccountTitle": "Switch account", + "switchAccountTitle": "Přepnout účet", "@switchAccountTitle": { "description": "Title of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." }, @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "Free {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 68744b4f..5aac1d6a 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "{bytes} freigeben", + "freeBytes": "{byteString} freigeben", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Die Verwendung von Paperless Mobile erfordert seit paperless-ngx 1.14.0 und höher ein Mindestmaß an Benutzerberechtigungen. Stelle deshalb bitte sicher, dass der anzumeldende Benutzer die Berechtigung hat, andere Benutzer (User → View) und die Einstellungen (UISettings → View) einzusehen. Falls du nicht über diese Berechtigungen verfügst, wende dich bitte an einen Administrator deines paperless-ngx Servers.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "Sie besitzen nicht die benötigten Berechtigungen, um diese Aktion durchzuführen.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b9620ca5..0a941ba6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "Free {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 403f810d..71dc73d0 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -812,53 +812,57 @@ "@goToLogin": { "description": "Label of the button shown on the login page to skip logging in to existing accounts and navigate user to login page" }, - "export": "Export", + "export": "Exporter", "@export": { "description": "Label for button that exports scanned images to pdf (before upload)" }, - "invalidFilenameCharacter": "Invalid character(s) found in filename: {characters}", + "invalidFilenameCharacter": "Caractère(s) invalide(s) trouvé dans le nom du fichier : {characters}", "@invalidFilenameCharacter": { "description": "For validating filename in export dialogue" }, - "exportScansToPdf": "Export scans to PDF", + "exportScansToPdf": "Exporter les scans en PDF", "@exportScansToPdf": { "description": "title of the alert dialog when exporting scans to pdf" }, - "allScansWillBeMerged": "All scans will be merged into a single PDF file.", - "behavior": "Behavior", + "allScansWillBeMerged": "Tous les scans seront fusionnés en un seul fichier PDF.", + "behavior": "Comportement", "@behavior": { "description": "Title of the settings concerning app beahvior" }, - "theme": "Theme", + "theme": "Thème", "@theme": { "description": "Title of the theme mode setting" }, - "clearCache": "Clear cache", + "clearCache": "Vider le cache", "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "{byteString} libres", "@freeBytes": { "description": "Text shown for clear storage settings" }, - "calculatingDots": "Calculating...", + "calculatingDots": "Calcul en cours...", "@calculatingDots": { "description": "Text shown when the byte size is still being calculated" }, - "freedDiskSpace": "Successfully freed {bytes} of disk space.", + "freedDiskSpace": "{bytes} d'espace disque libérés avec succès.", "@freedDiskSpace": { "description": "Message shown after clearing storage" }, - "uploadScansAsPdf": "Upload scans as PDF", + "uploadScansAsPdf": "Charger les scans au format PDF", "@uploadScansAsPdf": { "description": "Title of the setting which toggles whether scans are always uploaded as pdf" }, - "convertSinglePageScanToPdf": "Always convert single page scans to PDF before uploading", + "convertSinglePageScanToPdf": "Toujours convertir les scans d'une page en PDF avant de charger le document", "@convertSinglePageScanToPdf": { "description": "description of the upload scans as pdf setting" }, - "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", + "loginRequiredPermissionsHint": "L'utilisation de Paperless Mobile nécessite un ensemble minimal d'autorisations utilisateur depuis la version 1.14.0 et supérieure. Par conséquent, assurez-vous que l'utilisateur connecté a la permission de voir les autres utilisateurs (Utilisateur → Affichage) et les paramètres (UISettings → Affichage). Si vous ne disposez pas de ces autorisations, veuillez contacter un administrateur de votre serveur paperless-ngx.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index db132856..a6b5d0f7 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "Free {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 2abc0324..a893206f 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -21,11 +21,11 @@ "@addStoragePath": { "description": "Title when adding a new storage path" }, - "addTag": "New Tag", + "addTag": "Новый запрос", "@addTag": { "description": "Title when adding a new tag" }, - "aboutThisApp": "About this app", + "aboutThisApp": "О приложении", "@aboutThisApp": { "description": "Label for about this app tile displayed in the drawer" }, @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "Free {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 183a5a12..ea528091 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -837,7 +837,7 @@ "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {bytes}", + "freeBytes": "Free {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, @@ -860,5 +860,9 @@ "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "You do not have the necessary permissions to perform this action.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5c14e103..8b7a1e9d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -257,7 +257,10 @@ class _GoRouterShellState extends State { // ), // ], // ), - StatefulShellRoute.indexedStack( + StatefulShellRoute( + navigatorContainerBuilder: (context, navigationShell, children) { + return children[navigationShell.currentIndex]; + }, builder: const ScaffoldShellRoute().builder, branches: [ StatefulShellBranch( diff --git a/lib/theme.dart b/lib/theme.dart index d1543bab..34c37312 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -8,6 +8,12 @@ const _defaultListTileTheme = ListTileThemeData( tileColor: Colors.transparent, ); +final _defaultCardTheme = CardTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), +); + final _defaultInputDecorationTheme = InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -40,6 +46,7 @@ ThemeData buildTheme({ colorScheme: colorScheme.harmonized(), useMaterial3: true, ).copyWith( + cardTheme: _defaultCardTheme, inputDecorationTheme: _defaultInputDecorationTheme, listTileTheme: _defaultListTileTheme, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, diff --git a/packages/paperless_api/lib/src/models/paperless_server_statistics_model.dart b/packages/paperless_api/lib/src/models/paperless_server_statistics_model.dart index 77cd1889..26bfbc3f 100644 --- a/packages/paperless_api/lib/src/models/paperless_server_statistics_model.dart +++ b/packages/paperless_api/lib/src/models/paperless_server_statistics_model.dart @@ -1,13 +1,34 @@ class PaperlessServerStatisticsModel { final int documentsTotal; final int documentsInInbox; - + final int? totalChars; + final List fileTypeCounts; PaperlessServerStatisticsModel({ required this.documentsTotal, required this.documentsInInbox, + this.totalChars, + this.fileTypeCounts = const [], }); PaperlessServerStatisticsModel.fromJson(Map json) : documentsTotal = json['documents_total'] ?? 0, - documentsInInbox = json['documents_inbox'] ?? 0; + documentsInInbox = json['documents_inbox'] ?? 0, + totalChars = json["character_count"], + fileTypeCounts = (json['document_file_type_counts'] as List? ?? []) + .map((e) => DocumentFileTypeCount.fromJson(e)) + .toList(); +} + +class DocumentFileTypeCount { + final String mimeType; + final int count; + + DocumentFileTypeCount({ + required this.mimeType, + required this.count, + }); + + DocumentFileTypeCount.fromJson(Map json) + : mimeType = json['mime_type'], + count = json['mime_type_count']; } diff --git a/pubspec.lock b/pubspec.lock index 7c385445..7585a758 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -458,6 +458,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: c1e26c7e48496be85104c16c040950b0436674cdf0737f3f6e95511b2529b592 + url: "https://pub.dev" + source: hosted + version: "0.63.0" flutter: dependency: "direct main" description: flutter @@ -1108,6 +1116,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "0e3cd6974e10b1434dcf4cf779efddb80e2696585e273a2dbede6af52f94568d" + url: "https://pub.dev" + source: hosted + version: "0.3.3+2" paperless_api: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index cba64964..69088be8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,8 @@ dependencies: printing: ^5.11.0 flutter_pdfview: ^1.3.1 go_router: ^10.0.0 + fl_chart: ^0.63.0 + palette_generator: ^0.3.3+2 dependency_overrides: intl: ^0.18.1 From b79375cbe0088e891f8bff1795f2793f4af5eddc Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 1 Aug 2023 19:49:09 +0200 Subject: [PATCH 04/12] feat: Add saved views to landing page --- .../documents/view/pages/documents_page.dart | 20 ++--- .../widgets/items/document_grid_item.dart | 52 +++++++----- .../widgets/items/document_list_item.dart | 3 + lib/features/home/view/home_shell_widget.dart | 1 + lib/features/landing/view/landing_page.dart | 23 +++++ .../landing/view/widgets/expansion_card.dart | 4 +- .../cubit/saved_view_details_cubit.dart | 8 +- .../cubit/saved_view_preview_cubit.dart | 28 +++++++ .../cubit/saved_view_preview_state.dart | 11 +++ .../view/saved_view_details_preview.dart | 84 +++++++++++++++++++ 10 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart create mode 100644 lib/features/saved_view_details/cubit/saved_view_preview_state.dart create mode 100644 lib/features/saved_view_details/view/saved_view_details_preview.dart diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index f06cdc82..6db35d84 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -61,15 +61,15 @@ class _DocumentsPageState extends State length: showSavedViews ? 2 : 1, vsync: this, ); - Future.wait([ - context.read().reload(), - context.read().reload(), - ]).onError( - (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - return []; - }, - ); + // Future.wait([ + // context.read().reload(), + // context.read().reload(), + // ]).onError( + // (error, stackTrace) { + // showErrorMessage(context, error, stackTrace); + // return []; + // }, + // ); _tabController.addListener(_tabChangesListener); } @@ -117,7 +117,7 @@ class _DocumentsPageState extends State return SafeArea( top: true, child: Scaffold( - drawer: AppDrawer(), + drawer: const AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { final appliedFiltersCount = state.filter.appliedFiltersCount; diff --git a/lib/features/documents/view/widgets/items/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart index 9333610c..3dcc917a 100644 --- a/lib/features/documents/view/widgets/items/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/repository/label_repository.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; @@ -26,6 +28,7 @@ class DocumentGridItem extends DocumentItem { @override Widget build(BuildContext context) { + var currentUser = context.watch().paperlessUser; return Padding( padding: const EdgeInsets.all(8.0), child: Card( @@ -64,15 +67,16 @@ class DocumentGridItem extends DocumentItem { const SliverToBoxAdapter( child: SizedBox(width: 8), ), - TagsWidget.sliver( - tags: document.tags - .map((e) => context - .watch() - .state - .tags[e]!) - .toList(), - onTagSelected: onTagSelected, - ), + if (currentUser.canViewTags) + TagsWidget.sliver( + tags: document.tags + .map((e) => context + .watch() + .state + .tags[e]!) + .toList(), + onTagSelected: onTagSelected, + ), const SliverToBoxAdapter( child: SizedBox(width: 8), ), @@ -90,20 +94,22 @@ class DocumentGridItem extends DocumentItem { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CorrespondentWidget( - correspondent: context - .watch() - .state - .correspondents[document.correspondent], - onSelected: onCorrespondentSelected, - ), - DocumentTypeWidget( - documentType: context - .watch() - .state - .documentTypes[document.documentType], - onSelected: onDocumentTypeSelected, - ), + if (currentUser.canViewCorrespondents) + CorrespondentWidget( + correspondent: context + .watch() + .state + .correspondents[document.correspondent], + onSelected: onCorrespondentSelected, + ), + if (currentUser.canViewDocumentTypes) + DocumentTypeWidget( + documentType: context + .watch() + .state + .documentTypes[document.documentType], + onSelected: onDocumentTypeSelected, + ), Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Text( diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart index 1736eafa..1f066050 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -11,8 +11,10 @@ import 'package:provider/provider.dart'; class DocumentListItem extends DocumentItem { static const _a4AspectRatio = 1 / 1.4142; + final Color? backgroundColor; const DocumentListItem({ super.key, + this.backgroundColor, required super.document, required super.isSelected, required super.isSelectionActive, @@ -31,6 +33,7 @@ class DocumentListItem extends DocumentItem { final labels = context.watch().state; return Material( child: ListTile( + tileColor: backgroundColor, dense: true, selected: isSelected, onTap: () => _onTap(), diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index f0e07852..de636d2e 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -158,6 +158,7 @@ class HomeShellWidget extends StatelessWidget { return MultiProvider( providers: [ Provider( + lazy: false, create: (context) => DocumentsCubit( context.read(), context.read(), diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index 01a70274..c8521825 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -8,6 +8,8 @@ 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/landing/view/widgets/expansion_card.dart'; import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_preview.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -52,6 +54,27 @@ class _LandingPageState extends State { ).padded(24), ), SliverToBoxAdapter(child: _buildStatisticsCard(context)), + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loaded: (savedViews) { + return SliverList.builder( + itemBuilder: (context, index) { + return SavedViewDetailsPreview( + savedView: savedViews.values.elementAt(index), + ); + }, + itemCount: savedViews.length, + ); + }, + orElse: () => const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ) ], ), ), diff --git a/lib/features/landing/view/widgets/expansion_card.dart b/lib/features/landing/view/widgets/expansion_card.dart index 15bb0060..c90f8964 100644 --- a/lib/features/landing/view/widgets/expansion_card.dart +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; class ExpansionCard extends StatelessWidget { final Widget title; @@ -10,7 +9,7 @@ class ExpansionCard extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - margin: EdgeInsets.all(16), + margin: const EdgeInsets.all(16), child: Theme( data: Theme.of(context).copyWith( dividerColor: Colors.transparent, @@ -23,6 +22,7 @@ class ExpansionCard extends StatelessWidget { ), ), child: ExpansionTile( + backgroundColor: Theme.of(context).colorScheme.surface, initiallyExpanded: true, title: title, children: [content], diff --git a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart index 4109f7be..e965211c 100644 --- a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart @@ -29,6 +29,7 @@ class SavedViewDetailsCubit extends Cubit this._labelRepository, this._userState, { required this.savedView, + int initialCount = 25, }) : super( SavedViewDetailsState( correspondents: _labelRepository.state.correspondents, @@ -56,7 +57,12 @@ class SavedViewDetailsCubit extends Cubit } }, ); - updateFilter(filter: savedView.toDocumentFilter()); + updateFilter( + filter: savedView.toDocumentFilter().copyWith( + page: 1, + pageSize: initialCount, + ), + ); } void setViewType(ViewType viewType) { diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart new file mode 100644 index 00000000..19658f79 --- /dev/null +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -0,0 +1,28 @@ +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:paperless_api/paperless_api.dart'; + +part 'saved_view_preview_state.dart'; +part 'saved_view_preview_cubit.freezed.dart'; + +class SavedViewPreviewCubit extends Cubit { + final PaperlessDocumentsApi _api; + final SavedView view; + SavedViewPreviewCubit(this._api, this.view) + : super(const SavedViewPreviewState.initial()); + + Future initialize() async { + emit(const SavedViewPreviewState.loading()); + try { + final documents = await _api.findAll( + view.toDocumentFilter().copyWith( + page: 1, + pageSize: 5, + ), + ); + emit(SavedViewPreviewState.loaded(documents: documents.results)); + } catch (e) { + emit(const SavedViewPreviewState.error()); + } + } +} diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_state.dart b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart new file mode 100644 index 00000000..49e49959 --- /dev/null +++ b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart @@ -0,0 +1,11 @@ +part of 'saved_view_preview_cubit.dart'; + +@freezed +class SavedViewPreviewState with _$SavedViewPreviewState { + const factory SavedViewPreviewState.initial() = _Initial; + const factory SavedViewPreviewState.loading() = _Loading; + const factory SavedViewPreviewState.loaded({ + required List documents, + }) = _Loaded; + const factory SavedViewPreviewState.error() = _Error; +} diff --git a/lib/features/saved_view_details/view/saved_view_details_preview.dart b/lib/features/saved_view_details/view/saved_view_details_preview.dart new file mode 100644 index 00000000..5e33b9b9 --- /dev/null +++ b/lib/features/saved_view_details/view/saved_view_details_preview.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; +import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; +import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_preview_cubit.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:provider/provider.dart'; + +class SavedViewDetailsPreview extends StatelessWidget { + final SavedView savedView; + const SavedViewDetailsPreview({ + super.key, + required this.savedView, + }); + + @override + Widget build(BuildContext context) { + return Provider( + create: (context) => + SavedViewPreviewCubit(context.read(), savedView)..initialize(), + builder: (context, child) { + return ExpansionCard( + title: Text(savedView.name), + content: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + state.maybeWhen( + loaded: (documents) { + return Column( + children: [ + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + child: Text("Show more"), + onPressed: documents.length >= 5 ? () {} : null, + ), + TextButton.icon( + icon: Icon(Icons.open_in_new), + label: Text("Show in documents"), + onPressed: () { + context.read().updateFilter( + filter: savedView.toDocumentFilter(), + ); + DocumentsRoute().go(context); + }, + ), + ], + ), + ], + ); + }, + error: () => + const Text("Error loading preview"), //TODO: INTL + orElse: () => const Padding( + padding: EdgeInsets.all(8.0), + child: Center(child: CircularProgressIndicator()), + ), + ), + ], + ); + }, + ), + ); + }, + ); + } +} From 8e5eb5a6c60967a7f893dd768755dd762eb84f45 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 1 Aug 2023 21:58:20 +0200 Subject: [PATCH 05/12] Update Landing page --- ios/Podfile.lock | 73 ++++++++-------- lib/features/landing/view/landing_page.dart | 61 ++++++++------ .../landing/view/widgets/expansion_card.dart | 11 ++- .../view/widgets/mime_types_pie_chart.dart | 17 ++-- .../view/saved_view_details_preview.dart | 84 ------------------- .../view/saved_view_preview.dart | 78 +++++++++++++++++ 6 files changed, 166 insertions(+), 158 deletions(-) delete mode 100644 lib/features/saved_view_details/view/saved_view_details_preview.dart create mode 100644 lib/features/saved_view_details/view/saved_view_preview.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0c86e09e..1dc56907 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -35,7 +35,7 @@ PODS: - DKPhotoGallery/Resource (0.0.17): - SDWebImage - SwiftyGif - - edge_detection (1.1.1): + - edge_detection (1.1.2): - Flutter - WeScan - file_picker (0.0.1): @@ -48,14 +48,16 @@ PODS: - Flutter - flutter_native_splash (0.0.1): - Flutter + - flutter_pdfview (1.0.2): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter - fluttertoast (0.0.2): - Flutter - Toast - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) - - in_app_review (0.2.0): - - Flutter - integration_test (0.0.1): - Flutter - local_auth_ios (0.0.1): @@ -67,9 +69,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - pdfx (1.0.0): + - permission_handler_apple (9.1.1): - Flutter - - permission_handler_apple (9.0.4): + - printing (1.0.0): - Flutter - ReachabilitySwift (5.0.0) - receive_sharing_intent (0.0.1): @@ -79,16 +81,15 @@ PODS: - SDWebImage/Core (5.13.5) - share_plus (0.0.1): - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): - Flutter - FMDB (>= 2.7.5) - SwiftyGif (5.4.3) - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter - WeScan (1.7.0) DEPENDENCIES: @@ -100,20 +101,21 @@ DEPENDENCIES: - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_pdfview (from `.symlinks/plugins/flutter_pdfview/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) - - pdfx (from `.symlinks/plugins/pdfx/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - printing (from `.symlinks/plugins/printing/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) SPEC REPOS: trunk: @@ -143,10 +145,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_pdfview: + :path: ".symlinks/plugins/flutter_pdfview/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" - in_app_review: - :path: ".symlinks/plugins/in_app_review/ios" integration_test: :path: ".symlinks/plugins/integration_test/ios" local_auth_ios: @@ -156,54 +160,55 @@ EXTERNAL SOURCES: package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/ios" - pdfx: - :path: ".symlinks/plugins/pdfx/ios" + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + printing: + :path: ".symlinks/plugins/printing/ios" receive_sharing_intent: :path: ".symlinks/plugins/receive_sharing_intent/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: - connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e + connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - edge_detection: fa02aa120e00d87ada0ca2430b6c6087a501b1e9 + edge_detection: b4fb239b018cefa79515a024d0bf3e559336de4e file_picker: ce3938a0df3cc1ef404671531facef740d03f920 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 + flutter_pdfview: 25f53dd6097661e6395b17de506e6060585946bd + flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d - integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 - local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d + integration_test: 13825b8a9334a850581300559b8839134b124670 + local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605 open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 - package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 - pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec - permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce + package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + printing: 233e1b73bd1f4a05615548e9b5a324c98588640b ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 - shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca - sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993 + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7 PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13 -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.0 diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index c8521825..b8a1921c 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -9,12 +8,10 @@ import 'package:paperless_mobile/features/document_search/view/sliver_search_bar import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_preview.dart'; +import 'package:paperless_mobile/features/saved_view_details/view/saved_view_preview.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; -import 'package:fl_chart/fl_chart.dart'; class LandingPage extends StatefulWidget { const LandingPage({super.key}); @@ -45,7 +42,7 @@ class _LandingPageState extends State { slivers: [ SliverToBoxAdapter( child: Text( - "Welcome to Paperless Mobile, ${currentUser.fullName ?? currentUser.username}!", + "Welcome, ${currentUser.fullName ?? currentUser.username}!", textAlign: TextAlign.center, style: Theme.of(context) .textTheme @@ -54,17 +51,34 @@ class _LandingPageState extends State { ).padded(24), ), SliverToBoxAdapter(child: _buildStatisticsCard(context)), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 0, 8), + sliver: SliverToBoxAdapter( + child: Text( + "Saved Views", + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), BlocBuilder( builder: (context, state) { return state.maybeWhen( loaded: (savedViews) { + final dashboardViews = savedViews.values + .where((element) => element.showOnDashboard) + .toList(); + if (dashboardViews.isEmpty) { + return const SliverToBoxAdapter( + child: Text("No views"), + ); + } return SliverList.builder( itemBuilder: (context, index) { - return SavedViewDetailsPreview( - savedView: savedViews.values.elementAt(index), + return SavedViewPreview( + savedView: dashboardViews.elementAt(index), ); }, - itemCount: savedViews.length, + itemCount: dashboardViews.length, ); }, orElse: () => const SliverToBoxAdapter( @@ -83,6 +97,7 @@ class _LandingPageState extends State { } Widget _buildStatisticsCard(BuildContext context) { + final currentUser = context.read().paperlessUser; return FutureBuilder( future: context.read().getServerStatistics(), builder: (context, snapshot) { @@ -118,19 +133,13 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: Text("Documents in inbox:"), - onTap: () { - InboxRoute().go(context); - }, - trailing: Chip( - padding: EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - labelPadding: EdgeInsets.symmetric(horizontal: 4), - label: Text( - stats.documentsInInbox.toString(), - ), + title: const Text("Documents in inbox:"), + onTap: currentUser.canViewTags && currentUser.canViewDocuments + ? () => InboxRoute().go(context) + : null, + trailing: Text( + stats.documentsInInbox.toString(), + style: Theme.of(context).textTheme.labelLarge, ), ), ), @@ -139,16 +148,16 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: Text("Total documents:"), + title: const Text("Total documents:"), onTap: () { DocumentsRoute().go(context); }, trailing: Chip( - padding: EdgeInsets.symmetric( + padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), - labelPadding: EdgeInsets.symmetric(horizontal: 4), + labelPadding: const EdgeInsets.symmetric(horizontal: 4), label: Text( stats.documentsTotal.toString(), ), @@ -160,13 +169,13 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: Text("Total characters:"), + title: const Text("Total characters:"), trailing: Chip( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), - labelPadding: EdgeInsets.symmetric(horizontal: 4), + labelPadding: const EdgeInsets.symmetric(horizontal: 4), label: Text( stats.totalChars.toString(), ), diff --git a/lib/features/landing/view/widgets/expansion_card.dart b/lib/features/landing/view/widgets/expansion_card.dart index c90f8964..21695e5d 100644 --- a/lib/features/landing/view/widgets/expansion_card.dart +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -4,7 +4,14 @@ class ExpansionCard extends StatelessWidget { final Widget title; final Widget content; - const ExpansionCard({super.key, required this.title, required this.content}); + final bool initiallyExpanded; + + const ExpansionCard({ + super.key, + required this.title, + required this.content, + this.initiallyExpanded = false, + }); @override Widget build(BuildContext context) { @@ -23,7 +30,7 @@ class ExpansionCard extends StatelessWidget { ), child: ExpansionTile( backgroundColor: Theme.of(context).colorScheme.surface, - initiallyExpanded: true, + initiallyExpanded: initiallyExpanded, title: title, children: [content], ), diff --git a/lib/features/landing/view/widgets/mime_types_pie_chart.dart b/lib/features/landing/view/widgets/mime_types_pie_chart.dart index 81749b5a..6d6593fe 100644 --- a/lib/features/landing/view/widgets/mime_types_pie_chart.dart +++ b/lib/features/landing/view/widgets/mime_types_pie_chart.dart @@ -75,7 +75,7 @@ class _MimeTypesPieChartState extends State { ), ), Wrap( - alignment: WrapAlignment.start, + alignment: WrapAlignment.spaceAround, spacing: 8, runSpacing: 8, children: [ @@ -126,25 +126,18 @@ class _MimeTypesPieChartState extends State { for (int i = 0; i < widget.statistics.fileTypeCounts.length; i++) { final type = widget.statistics.fileTypeCounts[i]; final isTouched = i == _touchedIndex; - final fontSize = isTouched ? 24.0 : 16.0; + final fontSize = isTouched ? 18.0 : 16.0; final radius = isTouched ? 60.0 : 50.0; - const shadows = [ - Shadow(color: Colors.black, blurRadius: 2), - ]; - final color = colorShades[i % colorShades.length]; - final textColor = - color.computeLuminance() > 0.5 ? Colors.black : Colors.white; + final percentage = type.count / widget.statistics.documentsTotal * 100; yield PieChartSectionData( color: colorShades[i % colorShades.length], value: type.count.toDouble(), - title: ((type.count / widget.statistics.documentsTotal) * 100) - .toStringAsFixed(1) + - "%", + title: percentage.toStringAsFixed(1) + "%", radius: radius, titleStyle: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, - color: textColor, + color: Colors.black, ), ); } diff --git a/lib/features/saved_view_details/view/saved_view_details_preview.dart b/lib/features/saved_view_details/view/saved_view_details_preview.dart deleted file mode 100644 index 5e33b9b9..00000000 --- a/lib/features/saved_view_details/view/saved_view_details_preview.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; -import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_preview_cubit.dart'; -import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; -import 'package:provider/provider.dart'; - -class SavedViewDetailsPreview extends StatelessWidget { - final SavedView savedView; - const SavedViewDetailsPreview({ - super.key, - required this.savedView, - }); - - @override - Widget build(BuildContext context) { - return Provider( - create: (context) => - SavedViewPreviewCubit(context.read(), savedView)..initialize(), - builder: (context, child) { - return ExpansionCard( - title: Text(savedView.name), - content: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - state.maybeWhen( - loaded: (documents) { - return Column( - children: [ - for (final document in documents) - DocumentListItem( - document: document, - isLabelClickable: false, - isSelected: false, - isSelectionActive: false, - onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); - }, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - TextButton( - child: Text("Show more"), - onPressed: documents.length >= 5 ? () {} : null, - ), - TextButton.icon( - icon: Icon(Icons.open_in_new), - label: Text("Show in documents"), - onPressed: () { - context.read().updateFilter( - filter: savedView.toDocumentFilter(), - ); - DocumentsRoute().go(context); - }, - ), - ], - ), - ], - ); - }, - error: () => - const Text("Error loading preview"), //TODO: INTL - orElse: () => const Padding( - padding: EdgeInsets.all(8.0), - child: Center(child: CircularProgressIndicator()), - ), - ), - ], - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart new file mode 100644 index 00000000..2571f3ad --- /dev/null +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; +import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; +import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_preview_cubit.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:provider/provider.dart'; + +class SavedViewPreview extends StatelessWidget { + final SavedView savedView; + const SavedViewPreview({ + super.key, + required this.savedView, + }); + + @override + Widget build(BuildContext context) { + return Provider( + create: (context) => + SavedViewPreviewCubit(context.read(), savedView)..initialize(), + builder: (context, child) { + return ExpansionCard( + initiallyExpanded: true, + title: Text(savedView.name), + content: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loaded: (documents) { + return Column( + children: [ + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + child: Text("Show"), + onPressed: documents.length >= 5 ? () {} : null, + ), + TextButton.icon( + icon: Icon(Icons.open_in_new), + label: Text("Show in documents"), + onPressed: () { + context.read().updateFilter( + filter: savedView.toDocumentFilter(), + ); + DocumentsRoute().go(context); + }, + ), + ], + ), + ], + ); + }, + error: () => const Text("Error loading preview"), //TODO: INTL + orElse: () => const Padding( + padding: EdgeInsets.all(8.0), + child: Center(child: CircularProgressIndicator()), + ), + ); + }, + ), + ); + }, + ); + } +} From 2e8144700f5f1f5b6fa4bfa04ba140f71ea920c1 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 12 Sep 2023 01:03:01 +0200 Subject: [PATCH 06/12] Further migrations to route based navigation, improved saved view logic --- android/app/src/main/AndroidManifest.xml | 3 +- lib/core/navigation/push_routes.dart | 15 - .../repository/saved_view_repository.dart | 12 + .../error_code_localization_mapper.dart | 3 +- .../view/sliver_search_bar.dart | 3 - .../documents/view/pages/documents_page.dart | 357 ++++++++++++------ .../saved_view_changed_dialog.dart | 33 ++ .../widgets/saved_views/saved_view_chip.dart | 43 +++ .../saved_views/saved_views_widget.dart | 58 +++ .../widgets/search/document_filter_form.dart | 10 +- .../view/scaffold_with_navigation_bar.dart | 184 +++++---- lib/features/landing/view/landing_page.dart | 83 ++-- .../cubit/document_paging_bloc_mixin.dart | 2 +- .../saved_view/cubit/saved_view_cubit.dart | 4 + .../saved_view/view/add_saved_view_page.dart | 55 +-- .../view/saved_view_preview.dart | 37 +- lib/main.dart | 5 - .../typed/branches/saved_views_route.dart | 25 ++ .../lib/src/models/document_filter.dart | 8 + .../paperless_server_message_exception.dart | 8 +- .../src/models/paperless_api_exception.dart | 15 +- .../user_permission_extension.dart | 2 + .../lib/src/models/saved_view_model.dart | 21 ++ .../authentication_api_impl.dart | 13 +- .../paperless_saved_views_api.dart | 3 + .../paperless_saved_views_api_impl.dart | 16 + .../example/pubspec.lock | 132 +++---- pubspec.lock | 270 ++++++------- 28 files changed, 874 insertions(+), 546 deletions(-) create mode 100644 lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart create mode 100644 lib/features/documents/view/widgets/saved_views/saved_view_chip.dart create mode 100644 lib/features/documents/view/widgets/saved_views/saved_views_widget.dart create mode 100644 lib/routes/typed/branches/saved_views_route.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1e1dd10..40015960 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ + android:requestLegacyExternalStorage="true" + > pushSavedViewDetailsRoute( ); } -Future pushAddSavedViewRoute(BuildContext context, - {required DocumentFilter filter}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => AddSavedViewPage( - currentFilter: filter, - correspondents: context.read().state.correspondents, - documentTypes: context.read().state.documentTypes, - storagePaths: context.read().state.storagePaths, - tags: context.read().state.tags, - ), - ), - ); -} - Future pushBulkEditCorrespondentRoute( BuildContext context, { required List selection, diff --git a/lib/core/repository/saved_view_repository.dart b/lib/core/repository/saved_view_repository.dart index 234ceb3d..09fb82d1 100644 --- a/lib/core/repository/saved_view_repository.dart +++ b/lib/core/repository/saved_view_repository.dart @@ -35,6 +35,18 @@ class SavedViewRepository return created; } + Future update(SavedView object) async { + await _initialized.future; + final updated = await _api.update(object); + final updatedState = {...state.savedViews}..update( + updated.id!, + (_) => updated, + ifAbsent: () => updated, + ); + emit(SavedViewRepositoryState.loaded(savedViews: updatedState)); + return updated; + } + Future delete(SavedView view) async { await _initialized.future; await _api.delete(view); diff --git a/lib/core/translation/error_code_localization_mapper.dart b/lib/core/translation/error_code_localization_mapper.dart index 24d938c7..4e653e7c 100644 --- a/lib/core/translation/error_code_localization_mapper.dart +++ b/lib/core/translation/error_code_localization_mapper.dart @@ -54,7 +54,7 @@ String translateError(BuildContext context, ErrorCode code) { ErrorCode.suggestionsQueryError => S.of(context)!.couldNotLoadSuggestions, ErrorCode.acknowledgeTasksError => S.of(context)!.couldNotAcknowledgeTasks, ErrorCode.correspondentDeleteFailed => - "Could not delete correspondent, please try again.", + "Could not delete correspondent, please try again.", //TODO: INTL ErrorCode.documentTypeDeleteFailed => "Could not delete document type, please try again.", ErrorCode.tagDeleteFailed => "Could not delete tag, please try again.", @@ -73,5 +73,6 @@ String translateError(BuildContext context, ErrorCode code) { ErrorCode.uiSettingsLoadFailed => "Could not load UI settings", ErrorCode.loadTasksError => "Could not load tasks.", ErrorCode.userNotFound => "User could not be found.", + ErrorCode.updateSavedViewError => "Could not update saved view.", }; } diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 4dcf6659..072b331e 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -1,12 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hive/hive.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/features/document_search/view/document_search_bar.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 6db35d84..a8ce9206 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -5,15 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.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/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_views_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; @@ -24,6 +22,7 @@ import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:sliver_tools/sliver_tools.dart'; class DocumentFilterIntent { final DocumentFilter? filter; @@ -46,12 +45,29 @@ class _DocumentsPageState extends State with SingleTickerProviderStateMixin { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle(); - final SliverOverlapAbsorberHandle tabBarHandle = + final SliverOverlapAbsorberHandle savedViewsHandle = SliverOverlapAbsorberHandle(); late final TabController _tabController; int _currentTab = 0; + bool get hasSelectedViewChanged { + final cubit = context.watch(); + final savedViewCubit = context.watch(); + final activeView = savedViewCubit.state.maybeMap( + loaded: (state) { + if (cubit.state.filter.selectedView != null) { + return state.savedViews[cubit.state.filter.selectedView!]; + } + return null; + }, + orElse: () => null, + ); + final viewHasChanged = activeView != null && + activeView.toDocumentFilter() != cubit.state.filter; + return viewHasChanged; + } + @override void initState() { super.initState(); @@ -139,7 +155,7 @@ class _DocumentsPageState extends State .colorScheme .onPrimaryContainer, onPressed: () { - context.read().updateFilter(); + _onResetFilter(); }, child: Icon( Icons.refresh, @@ -195,107 +211,148 @@ class _DocumentsPageState extends State } return true; }, - child: NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return SliverSearchBar( - floating: true, - titleText: S.of(context)!.documents, - ); - } else { - return DocumentSelectionSliverAppBar( - state: state, - ); + child: Stack( + children: [ + NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: BlocBuilder( + builder: (context, state) { + if (state.selection.isEmpty) { + return SliverSearchBar( + floating: true, + titleText: S.of(context)!.documents, + ); + } else { + return DocumentSelectionSliverAppBar( + state: state, + ); + } + }, + ), + ), + SliverOverlapAbsorber( + handle: savedViewsHandle, + sliver: SliverPinnedHeader( + child: _buildViewActions(), + ), + ), + // SliverOverlapAbsorber( + // handle: tabBarHandle, + // sliver: BlocBuilder( + // builder: (context, state) { + // if (state.selection.isNotEmpty) { + // return const SliverToBoxAdapter( + // child: SizedBox.shrink(), + // ); + // } + // return SliverPersistentHeader( + // pinned: true, + // delegate: + // CustomizableSliverPersistentHeaderDelegate( + // minExtent: kTextTabBarHeight, + // maxExtent: kTextTabBarHeight, + // child: ColoredTabBar( + // tabBar: TabBar( + // controller: _tabController, + // tabs: [ + // Tab(text: S.of(context)!.documents), + // if (context + // .watch() + // .paperlessUser + // .canViewSavedViews) + // Tab(text: S.of(context)!.views), + // ], + // ), + // ), + // ), + // ); + // }, + // ), + // ), + ], + body: NotificationListener( + onNotification: (notification) { + final metrics = notification.metrics; + if (metrics.maxScrollExtent == 0) { + return true; + } + final desiredTab = + (metrics.pixels / metrics.maxScrollExtent) + .round(); + if (metrics.axis == Axis.horizontal && + _currentTab != desiredTab) { + setState(() => _currentTab = desiredTab); } + return false; }, + child: TabBarView( + controller: _tabController, + physics: context + .watch() + .state + .selection + .isNotEmpty + ? const NeverScrollableScrollPhysics() + : null, + children: [ + Builder( + builder: (context) { + return _buildDocumentsTab( + connectivityState, + context, + ); + }, + ), + if (context + .watch() + .paperlessUser + .canViewSavedViews) + Builder( + builder: (context) { + return _buildSavedViewsTab( + connectivityState, + context, + ); + }, + ), + ], + ), ), ), - SliverOverlapAbsorber( - handle: tabBarHandle, - sliver: BlocBuilder( - builder: (context, state) { - if (state.selection.isNotEmpty) { - return const SliverToBoxAdapter( - child: SizedBox.shrink(), - ); - } - return SliverPersistentHeader( - pinned: true, - delegate: - CustomizableSliverPersistentHeaderDelegate( - minExtent: kTextTabBarHeight, - maxExtent: kTextTabBarHeight, - child: ColoredTabBar( - tabBar: TabBar( - controller: _tabController, - tabs: [ - Tab(text: S.of(context)!.documents), - if (context - .watch() - .paperlessUser - .canViewSavedViews) - Tab(text: S.of(context)!.views), - ], + AnimatedOpacity( + opacity: hasSelectedViewChanged ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + child: Material( + borderRadius: BorderRadius.circular(24), + color: Theme.of(context) + .colorScheme + .surfaceVariant + .withOpacity(0.9), + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + onTap: () {}, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + "Update selected view", + style: Theme.of(context).textTheme.labelLarge, ), ), ), - ); - }, + ), + ), ), ), ], - body: NotificationListener( - onNotification: (notification) { - final metrics = notification.metrics; - if (metrics.maxScrollExtent == 0) { - return true; - } - final desiredTab = - (metrics.pixels / metrics.maxScrollExtent).round(); - if (metrics.axis == Axis.horizontal && - _currentTab != desiredTab) { - setState(() => _currentTab = desiredTab); - } - return false; - }, - child: TabBarView( - controller: _tabController, - physics: context - .watch() - .state - .selection - .isNotEmpty - ? const NeverScrollableScrollPhysics() - : null, - children: [ - Builder( - builder: (context) { - return _buildDocumentsTab( - connectivityState, - context, - ); - }, - ), - if (context - .watch() - .paperlessUser - .canViewSavedViews) - Builder( - builder: (context) { - return _buildSavedViewsTab( - connectivityState, - context, - ); - }, - ), - ], - ), - ), ), ), ), @@ -315,12 +372,12 @@ class _DocumentsPageState extends State notificationPredicate: (_) => connectivityState.isConnected, child: CustomScrollView( key: const PageStorageKey("savedViews"), - slivers: [ + slivers: [ SliverOverlapInjector( handle: searchBarHandle, ), SliverOverlapInjector( - handle: tabBarHandle, + handle: savedViewsHandle, ), const SavedViewList(), ], @@ -370,15 +427,40 @@ class _DocumentsPageState extends State key: const PageStorageKey("documents"), slivers: [ SliverOverlapInjector(handle: searchBarHandle), - SliverOverlapInjector(handle: tabBarHandle), - _buildViewActions(), + SliverOverlapInjector(handle: savedViewsHandle), + BlocBuilder( + buildWhen: (previous, current) => + previous.filter != current.filter, + builder: (context, state) { + return SliverToBoxAdapter( + child: SavedViewsWidget( + onViewSelected: (view) { + final cubit = context.read(); + if (state.filter.selectedView == view.id) { + _onResetFilter(); + } else { + cubit.updateFilter( + filter: view.toDocumentFilter(), + ); + } + }, + onUpdateView: (view) async { + await context.read().update(view); + showSnackBar(context, + "Saved view successfully updated."); //TODO: INTL + }, + filter: state.filter, + ), + ); + }, + ), BlocBuilder( builder: (context, state) { if (state.hasLoaded && state.documents.isEmpty) { return SliverToBoxAdapter( child: DocumentsEmptyState( state: state, - onReset: context.read().resetFilter, + onReset: _onResetFilter, ), ); } @@ -413,10 +495,12 @@ class _DocumentsPageState extends State } Widget _buildViewActions() { - return SliverToBoxAdapter( - child: BlocBuilder( - builder: (context, state) { - return Row( + return BlocBuilder( + builder: (context, state) { + return Container( + padding: EdgeInsets.all(4), + color: Theme.of(context).colorScheme.background, + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SortDocumentsButton( @@ -427,21 +511,22 @@ class _DocumentsPageState extends State onChanged: context.read().setViewType, ), ], - ); - }, - ).paddedSymmetrically(horizontal: 8, vertical: 4), + ), + ); + }, ); } void _onCreateSavedView(DocumentFilter filter) async { - final newView = await pushAddSavedViewRoute(context, filter: filter); - if (newView != null) { - try { - await context.read().add(newView); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } + //TODO: Implement + // final newView = await pushAddSavedViewRoute(context, filter: filter); + // if (newView != null) { + // try { + // await context.read().add(newView); + // } on PaperlessApiException catch (error, stackTrace) { + // showErrorMessage(context, error, stackTrace); + // } + // } } void _openDocumentFilter() async { @@ -485,7 +570,7 @@ class _DocumentsPageState extends State if (filterIntent != null) { try { if (filterIntent.shouldReset) { - await context.read().resetFilter(); + await _onResetFilter(); } else { await context .read() @@ -651,4 +736,40 @@ class _DocumentsPageState extends State showErrorMessage(context, error, stackTrace); } } + + Future _onResetFilter() async { + final cubit = context.read(); + final savedViewCubit = context.read(); + final activeView = savedViewCubit.state.maybeMap( + loaded: (state) { + if (cubit.state.filter.selectedView != null) { + return state.savedViews[cubit.state.filter.selectedView!]; + } + return null; + }, + orElse: () => null, + ); + final viewHasChanged = activeView != null && + activeView.toDocumentFilter() != cubit.state.filter; + if (viewHasChanged) { + final discardChanges = await showDialog( + context: context, + builder: (context) => SavedViewChangedDialog(), + ); + if (discardChanges == true) { + cubit.resetFilter(); + // Reset + } else if (discardChanges == false) { + final newView = activeView.copyWith( + filterRules: FilterRule.fromFilter(cubit.state.filter), + ); + final savedViewCubit2 = context.read(); + + await savedViewCubit2.update(newView); + showSnackBar(context, "Saved view successfully updated."); + } + } else { + cubit.resetFilter(); + } + } } diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart new file mode 100644 index 00000000..4db26ede --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.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/generated/l10n/app_localizations.dart'; + +class SavedViewChangedDialog extends StatelessWidget { + const SavedViewChangedDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Discard changes?"), //TODO: INTL + content: Text( + "Some filters of the currently active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", //TODO: INTL + ), + actionsOverflowButtonSpacing: 8, + actions: [ + const DialogCancelButton(), + TextButton( + child: Text(S.of(context)!.saveChanges), + onPressed: () { + Navigator.pop(context, false); + }, + ), + DialogConfirmButton( + label: S.of(context)!.resetFilter, + style: DialogConfirmButtonStyle.danger, + returnValue: true, + ), + ], + ); + } +} diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart new file mode 100644 index 00000000..399e39f9 --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:paperless_api/paperless_api.dart'; + +class SavedViewChip extends StatelessWidget { + final SavedView view; + final void Function(SavedView view) onViewSelected; + final void Function(SavedView vie) onUpdateView; + final bool selected; + final bool hasChanged; + + const SavedViewChip({ + super.key, + required this.view, + required this.onViewSelected, + required this.selected, + required this.hasChanged, + required this.onUpdateView, + }); + + @override + Widget build(BuildContext context) { + return Badge( + smallSize: 12, + alignment: const AlignmentDirectional(1.1, -1.2), + backgroundColor: Colors.red, + isLabelVisible: hasChanged, + child: FilterChip( + avatar: Icon( + Icons.saved_search, + color: Theme.of(context).colorScheme.onSurface, + ), + showCheckmark: false, + selectedColor: Theme.of(context).colorScheme.primaryContainer, + selected: selected, + label: Text(view.name), + onSelected: (_) { + onViewSelected(view); + }, + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart new file mode 100644 index 00000000..28c4b092 --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -0,0 +1,58 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; + +class SavedViewsWidget extends StatelessWidget { + final void Function(SavedView view) onViewSelected; + final void Function(SavedView view) onUpdateView; + final DocumentFilter filter; + const SavedViewsWidget({ + super.key, + required this.onViewSelected, + required this.filter, + required this.onUpdateView, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only( + top: 12, + left: 16, + right: 16, + ), + height: 50, + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loaded: (savedViews) { + if (savedViews.isEmpty) { + return Text("No saved views"); + } + return ListView.builder( + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final view = savedViews.values.elementAt(index); + return SavedViewChip( + view: view, + onUpdateView: onUpdateView, + onViewSelected: onViewSelected, + selected: filter.selectedView != null && + view.id == filter.selectedView, + hasChanged: filter.selectedView == view.id && + filter != view.toDocumentFilter(), + ); + }, + itemCount: savedViews.length, + ); + }, + error: () => Text("Error loading saved views"), + orElse: () => Placeholder(), + ); + }, + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/search/document_filter_form.dart b/lib/features/documents/view/widgets/search/document_filter_form.dart index 29cb7a01..30813a92 100644 --- a/lib/features/documents/view/widgets/search/document_filter_form.dart +++ b/lib/features/documents/view/widgets/search/document_filter_form.dart @@ -19,10 +19,12 @@ class DocumentFilterForm extends StatefulWidget { static const fkAddedAt = DocumentModel.addedKey; static DocumentFilter assembleFilter( - GlobalKey formKey, DocumentFilter initialFilter) { + GlobalKey formKey, + DocumentFilter initialFilter, + ) { formKey.currentState?.save(); final v = formKey.currentState!.value; - return DocumentFilter( + return initialFilter.copyWith( correspondent: v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ?? DocumentFilter.initial.correspondent, @@ -36,11 +38,7 @@ class DocumentFilterForm extends StatefulWidget { DocumentFilter.initial.query, created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery), added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery), - asnQuery: initialFilter.asnQuery, page: 1, - pageSize: initialFilter.pageSize, - sortField: initialFilter.sortField, - sortOrder: initialFilter.sortOrder, ); } diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index fab2344e..afc50b06 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -5,7 +5,6 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; const _landingPage = 0; const _documentsIndex = 1; @@ -30,134 +29,123 @@ class ScaffoldWithNavigationBar extends StatefulWidget { class ScaffoldWithNavigationBarState extends State { @override Widget build(BuildContext context) { - final disabledColor = Theme.of(context).disabledColor; final primaryColor = Theme.of(context).colorScheme.primary; + return Scaffold( drawer: const AppDrawer(), bottomNavigationBar: NavigationBar( selectedIndex: widget.navigationShell.currentIndex, onDestinationSelected: (index) { - switch (index) { - case _landingPage: - widget.navigationShell.goBranch(index); - break; - case _documentsIndex: - if (widget.authenticatedUser.canViewDocuments) { - widget.navigationShell.goBranch(index); - } else { - showSnackBar(context, S.of(context)!.missingPermissions); - } - break; - case _scannerIndex: - if (widget.authenticatedUser.canCreateDocuments) { - widget.navigationShell.goBranch(index); - } else { - showSnackBar(context, S.of(context)!.missingPermissions); - } - break; - case _labelsIndex: - if (widget.authenticatedUser.canViewAnyLabel) { - widget.navigationShell.goBranch(index); - } else { - showSnackBar(context, S.of(context)!.missingPermissions); - } - break; - case _inboxIndex: - if (widget.authenticatedUser.canViewDocuments && - widget.authenticatedUser.canViewTags) { - widget.navigationShell.goBranch(index); - } else { - showSnackBar(context, S.of(context)!.missingPermissions); - } - break; - default: - break; - } + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); }, destinations: [ NavigationDestination( - icon: Icon(Icons.home_outlined), + icon: const Icon(Icons.home_outlined), selectedIcon: Icon( Icons.home, color: primaryColor, ), label: "Home", //TODO: INTL ), - NavigationDestination( - icon: Icon( - Icons.description_outlined, - color: !widget.authenticatedUser.canViewDocuments - ? disabledColor - : null, - ), - selectedIcon: Icon( - Icons.description, - color: primaryColor, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.description_outlined), + selectedIcon: Icon( + Icons.description, + color: primaryColor, + ), + label: S.of(context)!.documents, ), - label: S.of(context)!.documents, + disableWhen: !widget.authenticatedUser.canViewDocuments, ), - NavigationDestination( - icon: Icon( - Icons.document_scanner_outlined, - color: !widget.authenticatedUser.canCreateDocuments - ? disabledColor - : null, - ), - selectedIcon: Icon( - Icons.document_scanner, - color: primaryColor, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.document_scanner_outlined), + selectedIcon: Icon( + Icons.document_scanner, + color: primaryColor, + ), + label: S.of(context)!.scanner, ), - label: S.of(context)!.scanner, + disableWhen: !widget.authenticatedUser.canCreateDocuments, ), - NavigationDestination( - icon: Icon( - Icons.sell_outlined, - color: !widget.authenticatedUser.canViewAnyLabel - ? disabledColor - : null, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.sell_outlined), + selectedIcon: Icon( + Icons.sell, + color: primaryColor, + ), + label: S.of(context)!.labels, ), - selectedIcon: Icon( - Icons.sell, - color: primaryColor, - ), - label: S.of(context)!.labels, + disableWhen: !widget.authenticatedUser.canViewAnyLabel, ), - NavigationDestination( - icon: Builder(builder: (context) { - if (!(widget.authenticatedUser.canViewDocuments && - widget.authenticatedUser.canViewTags)) { - return Icon( - Icons.inbox_outlined, - color: disabledColor, - ); - } - return BlocBuilder( + _toggleDestination( + NavigationDestination( + icon: Builder( + builder: (context) { + return BlocBuilder( + builder: (context, state) { + return Badge.count( + isLabelVisible: state.itemsInInboxCount > 0, + count: state.itemsInInboxCount, + child: const Icon(Icons.inbox_outlined), + ); + }, + ); + }, + ), + selectedIcon: BlocBuilder( builder: (context, state) { return Badge.count( - isLabelVisible: state.itemsInInboxCount > 0, + isLabelVisible: state.itemsInInboxCount > 0 && + widget.authenticatedUser.canViewInbox, count: state.itemsInInboxCount, - child: const Icon(Icons.inbox_outlined), + child: Icon( + Icons.inbox, + color: primaryColor, + ), ); }, - ); - }), - selectedIcon: BlocBuilder( - builder: (context, state) { - return Badge.count( - isLabelVisible: state.itemsInInboxCount > 0, - count: state.itemsInInboxCount, - child: Icon( - Icons.inbox, - color: primaryColor, - ), - ); - }, + ), + label: S.of(context)!.inbox, ), - label: S.of(context)!.inbox, + disableWhen: !widget.authenticatedUser.canViewInbox, ), ], ), body: widget.navigationShell, ); } + + Widget _toggleDestination( + Widget destination, { + required bool disableWhen, + }) { + final disabledColor = Theme.of(context).disabledColor; + + final disabledTheme = Theme.of(context).navigationBarTheme.copyWith( + labelTextStyle: MaterialStatePropertyAll( + Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: disabledColor), + ), + iconTheme: MaterialStatePropertyAll( + Theme.of(context).iconTheme.copyWith(color: disabledColor), + ), + ); + if (disableWhen) { + return AbsorbPointer( + child: Theme( + data: Theme.of(context).copyWith(navigationBarTheme: disabledTheme), + child: destination, + ), + ); + } + return destination; + } } diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index b8a1921c..7767dbf3 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -51,44 +51,58 @@ class _LandingPageState extends State { ).padded(24), ), SliverToBoxAdapter(child: _buildStatisticsCard(context)), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 16, 0, 8), - sliver: SliverToBoxAdapter( - child: Text( - "Saved Views", - style: Theme.of(context).textTheme.titleMedium, + if (currentUser.canViewSavedViews) ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 0, 8), + sliver: SliverToBoxAdapter( + child: Text( + "Saved Views", + style: Theme.of(context).textTheme.titleMedium, + ), ), ), - ), - BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - loaded: (savedViews) { - final dashboardViews = savedViews.values - .where((element) => element.showOnDashboard) - .toList(); - if (dashboardViews.isEmpty) { - return const SliverToBoxAdapter( - child: Text("No views"), - ); - } - return SliverList.builder( - itemBuilder: (context, index) { - return SavedViewPreview( - savedView: dashboardViews.elementAt(index), + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loaded: (savedViews) { + final dashboardViews = savedViews.values + .where((element) => element.showOnDashboard) + .toList(); + if (dashboardViews.isEmpty) { + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "There are no saved views to show on your dashboard.", //TODO: INTL + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: Text("Add new view"), + ) + ], + ).paddedOnly(left: 16), ); - }, - itemCount: dashboardViews.length, - ); - }, - orElse: () => const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), + } + return SliverList.builder( + itemBuilder: (context, index) { + return SavedViewPreview( + savedView: dashboardViews.elementAt(index), + ); + }, + itemCount: dashboardViews.length, + ); + }, + orElse: () => const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), ), - ), - ); - }, - ) + ); + }, + ), + ], ], ), ), @@ -121,6 +135,7 @@ class _LandingPageState extends State { } final stats = snapshot.data!; return ExpansionCard( + initiallyExpanded: false, title: Text( "Statistics", //TODO: INTL style: Theme.of(context).textTheme.titleLarge, diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 4d7ed16a..33b43b50 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -68,7 +68,7 @@ mixin DocumentPagingBlocMixin /// Convenience method which allows to directly use [DocumentFilter.copyWith] on the current filter. /// Future updateCurrentFilter( - final DocumentFilter Function(DocumentFilter) transformFn, + final DocumentFilter Function(DocumentFilter filter) transformFn, ) async => updateFilter(filter: transformFn(state.filter)); diff --git a/lib/features/saved_view/cubit/saved_view_cubit.dart b/lib/features/saved_view/cubit/saved_view_cubit.dart index 9215c2c8..c35d2357 100644 --- a/lib/features/saved_view/cubit/saved_view_cubit.dart +++ b/lib/features/saved_view/cubit/saved_view_cubit.dart @@ -35,6 +35,10 @@ class SavedViewCubit extends Cubit { return _savedViewRepository.delete(view); } + Future update(SavedView view) async { + return await _savedViewRepository.update(view); + } + Future reload() async { final views = await _savedViewRepository.findAll(); final values = {for (var element in views) element.id!: element}; diff --git a/lib/features/saved_view/view/add_saved_view_page.dart b/lib/features/saved_view/view/add_saved_view_page.dart index e24ad874..07a6613c 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,25 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; - import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AddSavedViewPage extends StatefulWidget { - final DocumentFilter currentFilter; - final Map correspondents; - final Map documentTypes; - final Map tags; - final Map storagePaths; + final DocumentFilter? initialFilter; const AddSavedViewPage({ super.key, - required this.currentFilter, - required this.correspondents, - required this.documentTypes, - required this.tags, - required this.storagePaths, + this.initialFilter, }); @override @@ -81,21 +71,6 @@ class _AddSavedViewPageState extends State { ), ), const Divider(), - Text( - "Review filter", - style: Theme.of(context).textTheme.bodyLarge, - ).padded(), - Flexible( - child: DocumentFilterForm( - padding: const EdgeInsets.symmetric(vertical: 8), - formKey: _filterFormKey, - initialFilter: widget.currentFilter, - correspondents: widget.correspondents, - documentTypes: widget.documentTypes, - storagePaths: widget.storagePaths, - tags: widget.tags, - ), - ), ], ), ), @@ -104,19 +79,19 @@ class _AddSavedViewPageState extends State { void _onCreate(BuildContext context) { if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { - context.pop( - SavedView.fromDocumentFilter( - DocumentFilterForm.assembleFilter( - _filterFormKey, - widget.currentFilter, - ), - name: _savedViewFormKey.currentState?.value[fkName] as String, - showOnDashboard: - _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool, - showInSidebar: - _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool, - ), - ); + // context.pop( + // SavedView.fromDocumentFilter( + // DocumentFilterForm.assembleFilter( + // _filterFormKey, + // widget.currentFilter, + // ), + // name: _savedViewFormKey.currentState?.value[fkName] as String, + // showOnDashboard: + // _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool, + // showInSidebar: + // _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool, + // ), + // ); } } } diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 2571f3ad..6dac4af1 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_preview_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:provider/provider.dart'; @@ -30,27 +32,26 @@ class SavedViewPreview extends StatelessWidget { loaded: (documents) { return Column( children: [ - for (final document in documents) - DocumentListItem( - document: document, - isLabelClickable: false, - isSelected: false, - isSelectionActive: false, - onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); - }, - ), + if (documents.isEmpty) + Text("This view is empty.").padded() + else + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + ), Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - child: Text("Show"), - onPressed: documents.length >= 5 ? () {} : null, - ), TextButton.icon( - icon: Icon(Icons.open_in_new), - label: Text("Show in documents"), + icon: const Icon(Icons.open_in_new), + label: Text("Show all"), //TODO: INTL onPressed: () { context.read().updateFilter( filter: savedView.toDocumentFilter(), diff --git a/lib/main.dart b/lib/main.dart index 8b7a1e9d..dfc2669b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -192,11 +192,6 @@ class GoRouterShell extends StatefulWidget { } class _GoRouterShellState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - @override void initState() { super.initState(); diff --git a/lib/routes/typed/branches/saved_views_route.dart b/lib/routes/typed/branches/saved_views_route.dart new file mode 100644 index 00000000..2967e542 --- /dev/null +++ b/lib/routes/typed/branches/saved_views_route.dart @@ -0,0 +1,25 @@ +import 'package:flutter/src/widgets/framework.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; + +@TypedGoRoute(path: "/saved-views", routes: []) +class SavedViewsRoute extends GoRouteData { + const SavedViewsRoute(); +} + +class CreateSavedViewRoute extends GoRouteData { + final DocumentFilter? $extra; + const CreateSavedViewRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return AddSavedViewPage( + initialFilter: $extra, + ); + } +} + +class EditSavedViewRoute extends GoRouteData { + const EditSavedViewRoute(); +} diff --git a/packages/paperless_api/lib/src/models/document_filter.dart b/packages/paperless_api/lib/src/models/document_filter.dart index ca2becd0..19ceea3c 100644 --- a/packages/paperless_api/lib/src/models/document_filter.dart +++ b/packages/paperless_api/lib/src/models/document_filter.dart @@ -63,6 +63,9 @@ class DocumentFilter extends Equatable { @HiveField(13) final int? moreLike; + @HiveField(14) + final int? selectedView; + const DocumentFilter({ this.documentType = const IdQueryParameter.unset(), this.correspondent = const IdQueryParameter.unset(), @@ -78,6 +81,7 @@ class DocumentFilter extends Equatable { this.created = const UnsetDateRangeQuery(), this.modified = const UnsetDateRangeQuery(), this.moreLike, + this.selectedView, }); bool get forceExtendedQuery { @@ -143,6 +147,7 @@ class DocumentFilter extends Equatable { DateRangeQuery? modified, TextQuery? query, int? Function()? moreLike, + int? Function()? selectedView, }) { final newFilter = DocumentFilter( pageSize: pageSize ?? this.pageSize, @@ -159,6 +164,7 @@ class DocumentFilter extends Equatable { created: created ?? this.created, modified: modified ?? this.modified, moreLike: moreLike != null ? moreLike.call() : this.moreLike, + selectedView: selectedView != null ? selectedView.call() : this.selectedView, ); if (query?.queryType != QueryType.extended && newFilter.forceExtendedQuery) { @@ -244,6 +250,8 @@ class DocumentFilter extends Equatable { created, modified, query, + moreLike, + selectedView, ]; factory DocumentFilter.fromJson(Map json) => diff --git a/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart index 1cad789e..b4794ea7 100644 --- a/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart +++ b/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart @@ -8,8 +8,12 @@ class PaperlessServerMessageException implements Exception { PaperlessServerMessageException(this.detail); - static bool canParse(Map json) { - return json.containsKey('detail') && json.length == 1; + static bool canParse(dynamic json) { + if (json is Map) { + return json.containsKey('detail') && json.length == 1; + } else { + return false; + } } factory PaperlessServerMessageException.fromJson(Map json) => diff --git a/packages/paperless_api/lib/src/models/paperless_api_exception.dart b/packages/paperless_api/lib/src/models/paperless_api_exception.dart index ac5fc02d..adaa8748 100644 --- a/packages/paperless_api/lib/src/models/paperless_api_exception.dart +++ b/packages/paperless_api/lib/src/models/paperless_api_exception.dart @@ -54,5 +54,18 @@ enum ErrorCode { unsupportedFileFormat, missingClientCertificate, acknowledgeTasksError, - correspondentDeleteFailed, documentTypeDeleteFailed, tagDeleteFailed, correspondentUpdateFailed, documentTypeUpdateFailed, tagUpdateFailed, storagePathDeleteFailed, storagePathUpdateFailed, serverInformationLoadFailed, serverStatisticsLoadFailed, uiSettingsLoadFailed, loadTasksError, userNotFound; + correspondentDeleteFailed, + documentTypeDeleteFailed, + tagDeleteFailed, + correspondentUpdateFailed, + documentTypeUpdateFailed, + tagUpdateFailed, + storagePathDeleteFailed, + storagePathUpdateFailed, + serverInformationLoadFailed, + serverStatisticsLoadFailed, + uiSettingsLoadFailed, + loadTasksError, + userNotFound, + updateSavedViewError; } diff --git a/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart b/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart index 0a6921ff..d6a01864 100644 --- a/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart +++ b/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart @@ -87,4 +87,6 @@ extension UserPermissionExtension on UserModel { canViewDocumentTypes || canViewTags || canViewStoragePaths; + + bool get canViewInbox => canViewTags && canViewDocuments; } diff --git a/packages/paperless_api/lib/src/models/saved_view_model.dart b/packages/paperless_api/lib/src/models/saved_view_model.dart index ae89590b..2918f289 100644 --- a/packages/paperless_api/lib/src/models/saved_view_model.dart +++ b/packages/paperless_api/lib/src/models/saved_view_model.dart @@ -50,11 +50,32 @@ class SavedView with EquatableMixin { Map toJson() => _$SavedViewToJson(this); + SavedView copyWith({ + int? id, + String? name, + bool? showOnDashboard, + bool? showInSidebar, + SortField? sortField, + bool? sortReverse, + List? filterRules, + }) { + return SavedView( + id: id ?? this.id, + name: name ?? this.name, + showOnDashboard: showOnDashboard ?? this.showOnDashboard, + showInSidebar: showInSidebar ?? this.showInSidebar, + sortField: sortField ?? this.sortField, + sortReverse: sortReverse ?? this.sortReverse, + filterRules: filterRules ?? this.filterRules, + ); + } + DocumentFilter toDocumentFilter() { return filterRules.fold( DocumentFilter( sortOrder: sortReverse ? SortOrder.descending : SortOrder.ascending, sortField: sortField, + selectedView: id, ), (filter, filterRule) => filterRule.applyToFilter(filter), ); diff --git a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart index fb7e9f20..631fbc3a 100644 --- a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; +import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/src/extensions/dio_exception_extension.dart'; -import 'package:paperless_api/src/modules/authentication_api/authentication_api.dart'; class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { final Dio client; @@ -20,10 +20,19 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { "password": password, }, options: Options( - validateStatus: (status) => status == 200, + followRedirects: false, + headers: { + "Accept": "application/json", + }, + validateStatus: (status) { + return status! == 200; + }, ), ); return response.data['token']; + // } else if (response.statusCode == 302) { + // final redirectUrl = response.headers.value("location"); + // return AuthenticationTemporaryRedirect(redirectUrl!); } on DioException catch (exception) { throw exception.unravel(); } diff --git a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api.dart b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api.dart index 8628cdd0..ef483cf2 100644 --- a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api.dart +++ b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api.dart @@ -6,4 +6,7 @@ abstract class PaperlessSavedViewsApi { Future save(SavedView view); Future delete(SavedView view); + + /// Since API V3 + Future update(SavedView view); } diff --git a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart index 976ba1c7..88512e57 100644 --- a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart @@ -41,6 +41,22 @@ class PaperlessSavedViewsApiImpl implements PaperlessSavedViewsApi { } } + @override + Future update(SavedView view) async { + try { + final response = await _client.patch( + "/api/saved_views/${view.id}/", + data: view.toJson(), + options: Options(validateStatus: (status) => status == 200), + ); + return SavedView.fromJson(response.data); + } on DioException catch (exception) { + throw exception.unravel( + orElse: const PaperlessApiException(ErrorCode.updateSavedViewError), + ); + } + } + @override Future delete(SavedView view) async { try { diff --git a/packages/paperless_document_scanner/example/pubspec.lock b/packages/paperless_document_scanner/example/pubspec.lock index a5c17787..433f843e 100644 --- a/packages/paperless_document_scanner/example/pubspec.lock +++ b/packages/paperless_document_scanner/example/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" + sha256: e0902a06f0e00414e4e3438a084580161279f137aeb862274710f29ec10cf01e url: "https://pub.dev" source: hosted - version: "3.3.7" + version: "3.3.9" async: dependency: transitive description: @@ -29,42 +29,42 @@ packages: dependency: "direct main" description: name: camera - sha256: ebebead3d5ec3d148249331d751d462d7e8c98102b8830a9b45ec96a2bd4333f + sha256: f63f2687fb1795c36f7c57b18a03071880eabb0fd8b5291b0fcd3fb979cb0fb1 url: "https://pub.dev" source: hosted - version: "0.10.5+2" + version: "0.10.5+4" camera_android: dependency: transitive description: name: camera_android - sha256: f43d07f9d7228ea1ca87d22e30881bd68da4b78484a1fbd1f1408b412a41cefb + sha256: ed4f645848074166fc3b8e20350f83ca07e09a2becc1e185040ee561f955d4df url: "https://pub.dev" source: hosted - version: "0.10.8+3" + version: "0.10.8+8" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "1a416e452b30955b392f4efbf23291d3f2ba3660a85e1628859eb62d2a2bab26" + sha256: "718b60ed2e22b4067fe6e2c0e9ebe2856c2de5c8b1289ba95d10db85b0b00bc2" url: "https://pub.dev" source: hosted - version: "0.9.13+2" + version: "0.9.13+4" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "60fa0bb62a4f3bf3a7c413e31e4cd01b69c779ccc8e4668904a24581b86c316b" + sha256: "8734d1c682f034bdb12d0d6ff379b0535a9b8e44266b530025bf8266d6a62f28" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.5.2" camera_web: dependency: transitive description: name: camera_web - sha256: bcbd775fb3a9d51cc3ece899d54ad66f6306410556bac5759f78e13f9228841f + sha256: d4c2c571c7af04f8b10702ca16bb9ed2a26e64534171e8f75c9349b2c004d8f1 url: "https://pub.dev" source: hosted - version: "0.3.1+4" + version: "0.3.2+3" camerawesome: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" colorfilter_generator: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb url: "https://pub.dev" source: hosted - version: "0.3.3+4" + version: "0.3.3+5" crypto: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" fake_async: dependency: transitive description: @@ -157,18 +157,10 @@ packages: dependency: transitive description: name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.0.2" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" + version: "2.1.0" flutter: dependency: "direct main" description: flutter @@ -178,18 +170,18 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "950e77c2bbe1692bc0874fc7fb491b96a4dc340457f4ea1641443d0a6c1ea360" + sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.0.16" flutter_test: dependency: "direct dev" description: flutter @@ -228,18 +220,18 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" matrix2d: dependency: transitive description: @@ -275,50 +267,50 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.2.0" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "1995d88ec2948dac43edf8fe58eb434d35d22a2940ecee1a9fefcd62beee6eb3" + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.1.11" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.1.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.2.1" petitparser: dependency: transitive description: @@ -331,18 +323,18 @@ packages: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.6" pointycastle: dependency: transitive description: @@ -351,14 +343,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.3" - process: - dependency: transitive - description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" - url: "https://pub.dev" - source: hosted - version: "4.2.4" quiver: dependency: transitive description: @@ -384,10 +368,10 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" stack_trace: dependency: transitive description: @@ -432,10 +416,10 @@ packages: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.0" typed_data: dependency: transitive description: @@ -452,22 +436,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" win32: dependency: transitive description: name: win32 - sha256: dfdf0136e0aa7a1b474ea133e67cb0154a0acd2599c4f3ada3b49d38d38793ee + sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" url: "https://pub.dev" source: hosted - version: "5.0.5" + version: "5.0.7" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" xml: dependency: transitive description: @@ -477,5 +469,5 @@ packages: source: hosted version: "6.3.0" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.3.0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.lock b/pubspec.lock index 7585a758..25656cd0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: "direct main" description: name: animations - sha256: fe8a6bdca435f718bb1dc8a11661b2c22504c6da40ef934cee8327ed77934164 + sha256: ef57563eed3620bd5d75ad96189846aca1e033c0c45fc9a7d26e80ab02b88a70 url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" ansicolor: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" + sha256: e0902a06f0e00414e4e3438a084580161279f137aeb862274710f29ec10cf01e url: "https://pub.dev" source: hosted - version: "3.3.7" + version: "3.3.9" args: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: bidi - sha256: "6794b226bc939731308b8539c49bb6c2fdbf0e78c3a65e9b9e81e727c256dfe6" + sha256: "2c162a66885167f1f92c41583efea9a435cb268d58322ba266613b9582440f87" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" bloc: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: "direct dev" description: name: bloc_test - sha256: "43d5b2f3d09ba768d6b611151bdf20ca141ffb46e795eb9550a58c9c2f4eae3f" + sha256: af0de1a1e16a7536e95dcd7491e0a6d6078e11d2d691988e862280b74f5c7968 url: "https://pub.dev" source: hosted - version: "9.1.3" + version: "9.1.4" boolean_selector: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.2" build_runner: dependency: "direct dev" description: @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: built_value - sha256: "598a2a682e2a7a90f08ba39c0aaa9374c5112340f0a2e275f61b59389543d166" + sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf url: "https://pub.dev" source: hosted - version: "8.6.1" + version: "8.6.2" cached_network_image: dependency: "direct main" description: @@ -245,26 +245,26 @@ packages: dependency: transitive description: name: code_builder - sha256: "4ad01d6e56db961d29661561effde45e519939fdaeb46c351275b182eac70189" + sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.6.0" collection: dependency: "direct main" description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" connectivity_plus: dependency: "direct main" description: name: connectivity_plus - sha256: "8599ae9edca5ff96163fca3e36f8e481ea917d1e71cdad912c084b5579913f34" + sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" connectivity_plus_platform_interface: dependency: transitive description: @@ -293,10 +293,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb url: "https://pub.dev" source: hosted - version: "0.3.3+4" + version: "0.3.3+5" crypto: dependency: transitive description: @@ -381,10 +381,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "3866d67f93523161b643187af65f5ac08bc991a5bcdaf41a2d587fe4ccb49993" + sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "5.3.2" dots_indicator: dependency: transitive description: @@ -406,7 +406,7 @@ packages: description: path: "." ref: master - resolved-ref: "01636d9050d409177934ec64876c1c83c2567513" + resolved-ref: "2e6c7396e13c2c6ecd0a704d2322b349a7a21584" url: "https://github.com/sawankumarbundelkhandi/edge_detection" source: git version: "1.1.2" @@ -430,10 +430,10 @@ packages: dependency: transitive description: name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" file: dependency: transitive description: @@ -621,10 +621,10 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: ba45d8cfbd778478a74696b012f33ffb6b1760c9bc531b21e2964444a4870dae + sha256: ecff62b3b893f2f665de7e4ad3de89f738941fcfcaaba8ee601e749efafa4698 url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" flutter_pdfview: dependency: "direct main" description: @@ -637,58 +637,58 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "950e77c2bbe1692bc0874fc7fb491b96a4dc340457f4ea1641443d0a6c1ea360" + sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.0.16" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5" + sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.1.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3" + sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.0" flutter_secure_storage_macos: dependency: transitive description: name: flutter_secure_storage_macos - sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50" + sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b + sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe" + sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee + sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" flutter_svg: dependency: "direct main" description: @@ -706,10 +706,10 @@ packages: dependency: "direct main" description: name: flutter_typeahead - sha256: a3539f7a90246b152f569029dedcf0b842532d3f2a440701b520e0bf2acbcf42 + sha256: f3a5f79d9a056e5108452dbec31d12bbd7f6d25e9097bf0f956e3f8d024e1747 url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "4.7.0" flutter_web_plugins: dependency: transitive description: flutter @@ -772,18 +772,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: b3cadd2cd59a4103fd5f6bc572ca75111264698784e927aa471921c3477d5475 + sha256: "5668e6d3dbcb2d0dfa25f7567554b88c57e1e3f3c440b672b24d4a9477017d5b" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.1.2" go_router_builder: dependency: "direct dev" description: name: go_router_builder - sha256: df2034629637d0c7c380aba5daa2f91be4733f2d632e7dff0b082d5ff3155068 + sha256: "89585f7cf2ddd35a3f05908c5bb54339d3f891fc5aac4f30e2864469d7ddc92b" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.1" graphs: dependency: transitive description: @@ -812,10 +812,10 @@ packages: dependency: "direct dev" description: name: hive_generator - sha256: "65998cc4d2cd9680a3d9709d893d2f6bb15e6c1f92626c3f1fa650b4b3281521" + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" html: dependency: transitive description: @@ -953,42 +953,42 @@ packages: dependency: "direct main" description: name: local_auth - sha256: "0cf238be2bfa51a6c9e7e9cfc11c05ea39f2a3a4d3e5bb255d0ebc917da24401" + sha256: "7e6c63082e399b61e4af71266b012e767a5d4525dd6e9ba41e174fd42d76e115" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.7" local_auth_android: dependency: transitive description: name: local_auth_android - sha256: "36a78898198386d36d4e152b8cb46059b18f0e2017f813a0e833e216199f8950" + sha256: "9ad0b1ffa6f04f4d91e38c2d4c5046583e23f4cae8345776a994e8670df57fb1" url: "https://pub.dev" source: hosted - version: "1.0.32" + version: "1.0.34" local_auth_ios: dependency: transitive description: name: local_auth_ios - sha256: edc2977c5145492f3451db9507a2f2f284ee4f408950b3e16670838726761940 + sha256: "26a8d1ad0b4ef6f861d29921be8383000fda952e323a5b6752cf82ca9cf9a7a9" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" local_auth_platform_interface: dependency: transitive description: name: local_auth_platform_interface - sha256: "9e160d59ef0743e35f1b50f4fb84dc64f55676b1b8071e319ef35e7f3bc13367" + sha256: fc5bd537970a324260fda506cfb61b33ad7426f37a8ea5c461cf612161ebba54 url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.8" local_auth_windows: dependency: transitive description: name: local_auth_windows - sha256: "5af808e108c445d0cf702a8c5f8242f1363b7970320334f82e6e1e8ad0b0d7d4" + sha256: "505ba3367ca781efb1c50d3132e44a2446bccc4163427bc203b9b4d8994d97ea" url: "https://pub.dev" source: hosted - version: "1.0.9" + version: "1.0.10" logging: dependency: transitive description: @@ -1001,18 +1001,18 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" meta: dependency: transitive description: @@ -1048,10 +1048,10 @@ packages: dependency: transitive description: name: mocktail - sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53" + sha256: "9503969a7c2c78c7292022c70c0289ed6241df7a9ba720010c0b215af29a5a58" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.0.0" nested: dependency: transitive description: @@ -1104,10 +1104,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: ceb027f6bc6a60674a233b4a90a7658af1aebdea833da0b5b53c1e9821a78c7b + sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.0" package_info_plus_platform_interface: dependency: transitive description: @@ -1120,10 +1120,10 @@ packages: dependency: "direct main" description: name: palette_generator - sha256: "0e3cd6974e10b1434dcf4cf779efddb80e2696585e273a2dbede6af52f94568d" + sha256: eb7082b4b97487ebc65b3ad3f6f0b7489b96e76840381ed0e06a46fe7ffd4068 url: "https://pub.dev" source: hosted - version: "0.3.3+2" + version: "0.3.3+3" paperless_api: dependency: "direct main" description: @@ -1159,50 +1159,50 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.2.0" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "916731ccbdce44d545414dd9961f26ba5fbaa74bcbb55237d8e65a623a8c7297" + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.1.11" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.1.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.2.1" pdf: dependency: "direct main" description: @@ -1215,18 +1215,18 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "63e5216aae014a72fe9579ccd027323395ce7a98271d9defa9d57320d001af81" + sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 url: "https://pub.dev" source: hosted - version: "10.4.3" + version: "10.4.5" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "2ffaf52a21f64ac9b35fe7369bb9533edbd4f698e5604db8645b1064ff4cf221" + sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" url: "https://pub.dev" source: hosted - version: "10.3.3" + version: "10.3.6" permission_handler_apple: dependency: transitive description: @@ -1239,10 +1239,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: "7c6b1500385dd1d2ca61bb89e2488ca178e274a69144d26bbd65e33eae7c02a9" + sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 url: "https://pub.dev" source: hosted - version: "3.11.3" + version: "3.11.5" permission_handler_windows: dependency: transitive description: @@ -1279,18 +1279,18 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: "43798d895c929056255600343db8f049921cbec94d31ec87f1dc5c16c01935dd" + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" pointer_interceptor: dependency: transitive description: name: pointer_interceptor - sha256: "6aa680b30d96dccef496933d00208ad25f07e047f644dc98ce03ec6141633a9a" + sha256: "7626e034489820fd599380d2bb4d3f4a0a5e3529370b62bfce53ab736b91adb2" url: "https://pub.dev" source: hosted - version: "0.9.3+4" + version: "0.9.3+6" pointycastle: dependency: transitive description: @@ -1423,10 +1423,10 @@ packages: dependency: transitive description: name: share_plus_platform_interface - sha256: "0c6e61471bd71b04a138b8b588fa388e66d8b005e6f2deda63371c5c505a0981" + sha256: "357412af4178d8e11d14f41723f80f12caea54cf0d5cd29af9dcdab85d58aea7" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.3.0" share_plus_web: dependency: transitive description: @@ -1540,10 +1540,10 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" sqflite: dependency: transitive description: @@ -1620,26 +1620,26 @@ packages: dependency: transitive description: name: test - sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" url: "https://pub.dev" source: hosted - version: "1.24.1" + version: "1.24.3" test_api: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.0" test_core: dependency: transitive description: name: test_core - sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.3" timezone: dependency: transitive description: @@ -1676,66 +1676,66 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" url: "https://pub.dev" source: hosted - version: "6.1.12" + version: "6.1.14" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "78cb6dea3e93148615109e58e42c35d1ffbf5ef66c44add673d0ab75f12ff3af" + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 url: "https://pub.dev" source: hosted - version: "6.0.37" + version: "6.1.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2" + sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "6.1.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "207f4ddda99b95b4d4868320a352d374b0b7e05eefad95a4a26f57da413443f5" + sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "1c4fdc0bfea61a70792ce97157e5cc17260f61abbe4f39354513f39ec6fd73b1" + sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: bfdfa402f1f3298637d71ca8ecfe840b4696698213d5346e9d12d4ab647ee2ea + sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: cc26720eefe98c1b71d85f9dc7ef0cada5132617046369d9dc296b3ecaa5cbb4 + sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" url: "https://pub.dev" source: hosted - version: "2.0.18" + version: "2.0.20" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "7967065dd2b5fccc18c653b97958fdf839c5478c28e767c61ee879f4e7882422" + sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.0.8" uuid: dependency: "direct main" description: @@ -1756,10 +1756,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6deed8ed625c52864792459709183da231ebf66ff0cf09e69b573227c377efe + sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "11.7.1" watcher: dependency: transitive description: @@ -1768,6 +1768,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" web_socket_channel: dependency: "direct main" description: @@ -1788,42 +1796,42 @@ packages: dependency: transitive description: name: webkit_inspection_protocol - sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" webview_flutter: dependency: "direct main" description: name: webview_flutter - sha256: "789d52bd789373cc1e100fb634af2127e86c99cf9abde09499743270c5de8d00" + sha256: "82f6787d5df55907aa01e49bd9644f4ed1cc82af7a8257dd9947815959d2e755" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.2.4" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: d936a09fbfd08cb78f7329e0bbacf6158fbdfe24ffc908b22444c07d295eb193 + sha256: "9427774649fd3c8b7ff53523051395d13aed2ca355822b822e6493d79f5fc05a" url: "https://pub.dev" source: hosted - version: "3.9.2" + version: "3.10.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "564ef378cafc1a0e29f1d76ce175ef517a0a6115875dff7b43fccbef2b0aeb30" + sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.6.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: "5fa098f28b606f699e8ca52d9e4e11edbbfef65189f5f77ae92703ba5408fd25" + sha256: d2f7241849582da80b79acb03bb936422412ce5c0c79fb5f6a1de5421a5aecc4 url: "https://pub.dev" source: hosted - version: "3.7.2" + version: "3.7.4" win32: dependency: transitive description: @@ -1857,5 +1865,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" From f3560f00ea747e27d5f0481ae7d74c8b84971248 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 19 Sep 2023 01:50:02 +0200 Subject: [PATCH 07/12] feat: Migrations, new saved views interface --- assets/images/github-mark.svg | 1 + lib/features/app_drawer/view/app_drawer.dart | 53 +- .../documents/view/pages/documents_page.dart | 275 +++--- .../widgets/items/document_list_item.dart | 152 ++- .../saved_view_changed_dialog.dart | 12 +- .../widgets/saved_views/saved_view_chip.dart | 159 +++- .../saved_views/saved_views_widget.dart | 153 ++- .../confirm_delete_saved_view_dialog.dart | 2 +- .../edit_label/view/impl/add_tag_page.dart | 11 +- .../edit_label/view/impl/edit_tag_page.dart | 12 +- lib/features/edit_label/view/label_form.dart | 10 +- lib/features/landing/view/landing_page.dart | 17 +- .../landing/view/widgets/expansion_card.dart | 2 +- .../saved_view/view/add_saved_view_page.dart | 67 +- .../saved_view/view/edit_saved_view_page.dart | 106 +++ .../view/saved_view_preview.dart | 10 +- .../widgets/language_selection_setting.dart | 3 +- lib/helpers/message_helpers.dart | 1 + lib/l10n/intl_ca.arb | 14 +- lib/l10n/intl_cs.arb | 12 + lib/l10n/intl_de.arb | 12 + lib/l10n/intl_en.arb | 12 + lib/l10n/intl_es.arb | 880 ++++++++++++++++++ lib/l10n/intl_fr.arb | 12 + lib/l10n/intl_pl.arb | 14 +- lib/l10n/intl_ru.arb | 14 +- lib/l10n/intl_tr.arb | 14 +- lib/main.dart | 24 +- lib/routes/routes.dart | 1 + .../typed/branches/saved_views_route.dart | 30 +- .../models/query_parameters/text_query.dart | 24 +- 31 files changed, 1739 insertions(+), 370 deletions(-) create mode 100644 assets/images/github-mark.svg create mode 100644 lib/features/saved_view/view/edit_saved_view_page.dart create mode 100644 lib/l10n/intl_es.arb diff --git a/assets/images/github-mark.svg b/assets/images/github-mark.svg new file mode 100644 index 00000000..37fa923d --- /dev/null +++ b/assets/images/github-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 627e893b..11402a92 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -1,17 +1,11 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; -import 'package:paperless_mobile/features/settings/view/settings_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; -import 'package:provider/provider.dart'; - import 'package:url_launcher/url_launcher_string.dart'; class AppDrawer extends StatelessWidget { @@ -61,27 +55,40 @@ class AppDrawer extends StatelessWidget { ), ListTile( dense: true, - leading: Padding( - padding: const EdgeInsets.only(left: 3), - child: SvgPicture.asset( - 'assets/images/bmc-logo.svg', - width: 24, - height: 24, - ), + leading: const Icon(Icons.favorite_outline), + title: Text(S.of(context)!.donate), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.favorite), + title: Text(S.of(context)!.donate), + content: Text( + S.of(context)!.donationDialogContent, + ), + actions: const [ + Text("~ Anton"), + ], + ), + ); + }, + ), + ListTile( + dense: true, + leading: SvgPicture.asset( + "assets/images/github-mark.svg", + color: Theme.of(context).colorScheme.onBackground, + height: 24, + width: 24, ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context)!.donateCoffee), - const Icon( - Icons.open_in_new, - size: 16, - ) - ], + title: Text(S.of(context)!.sourceCode), + trailing: const Icon( + Icons.open_in_new, + size: 16, ), onTap: () { launchUrlString( - "https://www.buymeacoffee.com/astubenbord", + "https://github.com/astubenbord/paperless-mobile", mode: LaunchMode.externalApplication, ); }, diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index a8ce9206..c6ae53f9 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,6 +1,7 @@ import 'package:badges/badges.dart' as b; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; @@ -13,6 +14,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/documents_empty import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_views_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; @@ -45,29 +47,13 @@ class _DocumentsPageState extends State with SingleTickerProviderStateMixin { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle(); + final SliverOverlapAbsorberHandle savedViewsHandle = SliverOverlapAbsorberHandle(); late final TabController _tabController; int _currentTab = 0; - - bool get hasSelectedViewChanged { - final cubit = context.watch(); - final savedViewCubit = context.watch(); - final activeView = savedViewCubit.state.maybeMap( - loaded: (state) { - if (cubit.state.filter.selectedView != null) { - return state.savedViews[cubit.state.filter.selectedView!]; - } - return null; - }, - orElse: () => null, - ); - final viewHasChanged = activeView != null && - activeView.toDocumentFilter() != cubit.state.filter; - return viewHasChanged; - } - + final _savedViewsExpansionController = ExpansionTileController(); @override void initState() { super.initState(); @@ -177,21 +163,14 @@ class _DocumentsPageState extends State animationType: b.BadgeAnimationType.fade, badgeColor: Colors.red, child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: (_currentTab == 0) - ? FloatingActionButton( - heroTag: "fab_documents_page_filter", - child: - const Icon(Icons.filter_alt_outlined), - onPressed: _openDocumentFilter, - ) - : FloatingActionButton( - heroTag: "fab_documents_page_filter", - child: const Icon(Icons.add), - onPressed: () => - _onCreateSavedView(state.filter), - ), - ), + duration: const Duration(milliseconds: 250), + child: Builder(builder: (context) { + return FloatingActionButton( + heroTag: "fab_documents_page_filter", + child: const Icon(Icons.filter_alt_outlined), + onPressed: _openDocumentFilter, + ); + })), ), ], ), @@ -236,7 +215,10 @@ class _DocumentsPageState extends State SliverOverlapAbsorber( handle: savedViewsHandle, sliver: SliverPinnedHeader( - child: _buildViewActions(), + child: Material( + child: _buildViewActions(), + elevation: 4, + ), ), ), // SliverOverlapAbsorber( @@ -288,70 +270,13 @@ class _DocumentsPageState extends State } return false; }, - child: TabBarView( - controller: _tabController, - physics: context - .watch() - .state - .selection - .isNotEmpty - ? const NeverScrollableScrollPhysics() - : null, - children: [ - Builder( - builder: (context) { - return _buildDocumentsTab( - connectivityState, - context, - ); - }, - ), - if (context - .watch() - .paperlessUser - .canViewSavedViews) - Builder( - builder: (context) { - return _buildSavedViewsTab( - connectivityState, - context, - ); - }, - ), - ], - ), - ), - ), - AnimatedOpacity( - opacity: hasSelectedViewChanged ? 1 : 0, - duration: const Duration(milliseconds: 300), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - margin: const EdgeInsets.only(bottom: 24), - child: Material( - borderRadius: BorderRadius.circular(24), - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withOpacity(0.9), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - onTap: () {}, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - "Update selected view", - style: Theme.of(context).textTheme.labelLarge, - ), - ), - ), - ), + child: _buildDocumentsTab( + connectivityState, + context, ), ), ), + _buildSavedViewChangedIndicator(), ], ), ), @@ -362,29 +287,82 @@ class _DocumentsPageState extends State ); } - Widget _buildSavedViewsTab( - ConnectivityState connectivityState, - BuildContext context, - ) { - return RefreshIndicator( - edgeOffset: kTextTabBarHeight, - onRefresh: _onReloadSavedViews, - notificationPredicate: (_) => connectivityState.isConnected, - child: CustomScrollView( - key: const PageStorageKey("savedViews"), - slivers: [ - SliverOverlapInjector( - handle: searchBarHandle, - ), - SliverOverlapInjector( - handle: savedViewsHandle, + Widget _buildSavedViewChangedIndicator() { + return BlocBuilder( + builder: (context, state) { + final savedViewCubit = context.watch(); + final activeView = savedViewCubit.state.maybeMap( + loaded: (savedViewState) { + if (state.filter.selectedView != null) { + return savedViewState.savedViews[state.filter.selectedView!]; + } + return null; + }, + orElse: () => null, + ); + final viewHasChanged = + activeView != null && activeView.toDocumentFilter() != state.filter; + return AnimatedScale( + scale: viewHasChanged ? 1 : 0, + alignment: Alignment.bottomCenter, + duration: const Duration(milliseconds: 300), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + child: Material( + borderRadius: BorderRadius.circular(24), + color: Theme.of(context) + .colorScheme + .surfaceVariant + .withOpacity(0.9), + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + onTap: () async { + await _updateCurrentSavedView(); + setState(() {}); + }, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + "Update selected view", + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + ), + ), ), - const SavedViewList(), - ], - ), + ); + }, ); } + // Widget _buildSavedViewsTab( + // ConnectivityState connectivityState, + // BuildContext context, + // ) { + // return RefreshIndicator( + // edgeOffset: kTextTabBarHeight, + // onRefresh: _onReloadSavedViews, + // notificationPredicate: (_) => connectivityState.isConnected, + // child: CustomScrollView( + // key: const PageStorageKey("savedViews"), + // slivers: [ + // SliverOverlapInjector( + // handle: searchBarHandle, + // ), + // SliverOverlapInjector( + // handle: savedViewsHandle, + // ), + // const SavedViewList(), + // ], + // ), + // ); + // } + Widget _buildDocumentsTab( ConnectivityState connectivityState, BuildContext context, @@ -393,6 +371,10 @@ class _DocumentsPageState extends State onNotification: (notification) { // Listen for scroll notifications to load new data. // Scroll controller does not work here due to nestedscrollview limitations. + final offset = notification.metrics.pixels; + if (offset > 128 && _savedViewsExpansionController.isExpanded) { + _savedViewsExpansionController.collapse(); + } final currState = context.read().state; final max = notification.metrics.maxScrollExtent; @@ -403,7 +385,6 @@ class _DocumentsPageState extends State return false; } - final offset = notification.metrics.pixels; if (offset >= max * 0.7) { context .read() @@ -420,7 +401,7 @@ class _DocumentsPageState extends State return false; }, child: RefreshIndicator( - edgeOffset: kTextTabBarHeight, + edgeOffset: kTextTabBarHeight + 2, onRefresh: _onReloadDocuments, notificationPredicate: (_) => connectivityState.isConnected, child: CustomScrollView( @@ -434,6 +415,7 @@ class _DocumentsPageState extends State builder: (context, state) { return SliverToBoxAdapter( child: SavedViewsWidget( + controller: _savedViewsExpansionController, onViewSelected: (view) { final cubit = context.read(); if (state.filter.selectedView == view.id) { @@ -449,6 +431,22 @@ class _DocumentsPageState extends State showSnackBar(context, "Saved view successfully updated."); //TODO: INTL }, + onDeleteView: (view) async { + HapticFeedback.mediumImpact(); + final shouldRemove = await showDialog( + context: context, + builder: (context) => + ConfirmDeleteSavedViewDialog(view: view), + ); + if (shouldRemove) { + final documentsCubit = context.read(); + context.read().remove(view); + if (documentsCubit.state.filter.selectedView == + view.id) { + documentsCubit.resetFilter(); + } + } + }, filter: state.filter, ), ); @@ -722,16 +720,10 @@ class _DocumentsPageState extends State Future _onReloadDocuments() async { try { // We do not await here on purpose so we can show a linear progress indicator below the app bar. - await context.read().reload(); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - - Future _onReloadSavedViews() async { - try { - // We do not await here on purpose so we can show a linear progress indicator below the app bar. - await context.read().reload(); + await Future.wait([ + context.read().reload(), + context.read().reload(), + ]); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } @@ -754,22 +746,37 @@ class _DocumentsPageState extends State if (viewHasChanged) { final discardChanges = await showDialog( context: context, - builder: (context) => SavedViewChangedDialog(), + builder: (context) => const SavedViewChangedDialog(), ); if (discardChanges == true) { cubit.resetFilter(); // Reset } else if (discardChanges == false) { - final newView = activeView.copyWith( - filterRules: FilterRule.fromFilter(cubit.state.filter), - ); - final savedViewCubit2 = context.read(); - - await savedViewCubit2.update(newView); - showSnackBar(context, "Saved view successfully updated."); + _updateCurrentSavedView(); } } else { cubit.resetFilter(); } } + + Future _updateCurrentSavedView() async { + final savedViewCubit = context.read(); + final cubit = context.read(); + final activeView = savedViewCubit.state.maybeMap( + loaded: (state) { + if (cubit.state.filter.selectedView != null) { + return state.savedViews[cubit.state.filter.selectedView!]; + } + return null; + }, + orElse: () => null, + ); + if (activeView == null) return; + final newView = activeView.copyWith( + filterRules: FilterRule.fromFilter(cubit.state.filter), + ); + + await savedViewCubit.update(newView); + showSnackBar(context, "Saved view successfully updated."); + } } diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart index 1f066050..11c8d6aa 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -31,92 +31,90 @@ class DocumentListItem extends DocumentItem { @override Widget build(BuildContext context) { final labels = context.watch().state; - return Material( - child: ListTile( - tileColor: backgroundColor, - dense: true, - selected: isSelected, - onTap: () => _onTap(), - selectedTileColor: Theme.of(context).colorScheme.inversePrimary, - onLongPress: () => onSelected?.call(document), - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Row( - children: [ - AbsorbPointer( - absorbing: isSelectionActive, - child: CorrespondentWidget( - isClickable: isLabelClickable, - correspondent: context - .watch() - .state - .correspondents[document.correspondent], - onSelected: onCorrespondentSelected, - ), + return ListTile( + tileColor: backgroundColor, + dense: true, + selected: isSelected, + onTap: () => _onTap(), + selectedTileColor: Theme.of(context).colorScheme.inversePrimary, + onLongPress: onSelected != null ? () => onSelected!(document) : null, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + children: [ + AbsorbPointer( + absorbing: isSelectionActive, + child: CorrespondentWidget( + isClickable: isLabelClickable, + correspondent: context + .watch() + .state + .correspondents[document.correspondent], + onSelected: onCorrespondentSelected, ), - ], - ), - Text( - document.title, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - AbsorbPointer( - absorbing: isSelectionActive, - child: TagsWidget( - isClickable: isLabelClickable, - tags: document.tags - .where((e) => labels.tags.containsKey(e)) - .map((e) => labels.tags[e]!) - .toList(), - onTagSelected: (id) => onTagSelected?.call(id), ), - ), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: RichText( - maxLines: 1, + ], + ), + Text( + document.title, overflow: TextOverflow.ellipsis, - text: TextSpan( - text: DateFormat.yMMMd().format(document.created), - style: Theme.of(context) - .textTheme - .labelSmall - ?.apply(color: Colors.grey), - children: document.documentType != null - ? [ - const TextSpan(text: '\u30FB'), - TextSpan( - text: labels.documentTypes[document.documentType]?.name, - recognizer: onDocumentTypeSelected != null - ? (TapGestureRecognizer() - ..onTap = () => onDocumentTypeSelected!( - document.documentType)) - : null, - ), - ] - : null, + maxLines: 1, + ), + AbsorbPointer( + absorbing: isSelectionActive, + child: TagsWidget( + isClickable: isLabelClickable, + tags: document.tags + .where((e) => labels.tags.containsKey(e)) + .map((e) => labels.tags[e]!) + .toList(), + onTagSelected: (id) => onTagSelected?.call(id), ), ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: DateFormat.yMMMd().format(document.created), + style: Theme.of(context) + .textTheme + .labelSmall + ?.apply(color: Colors.grey), + children: document.documentType != null + ? [ + const TextSpan(text: '\u30FB'), + TextSpan( + text: labels.documentTypes[document.documentType]?.name, + recognizer: onDocumentTypeSelected != null + ? (TapGestureRecognizer() + ..onTap = () => + onDocumentTypeSelected!(document.documentType)) + : null, + ), + ] + : null, + ), ), - isThreeLine: document.tags.isNotEmpty, - leading: AspectRatio( - aspectRatio: _a4AspectRatio, - child: GestureDetector( - child: DocumentPreview( - document: document, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - enableHero: enableHeroAnimation, - ), + ), + isThreeLine: document.tags.isNotEmpty, + leading: AspectRatio( + aspectRatio: _a4AspectRatio, + child: GestureDetector( + child: DocumentPreview( + document: document, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + enableHero: enableHeroAnimation, ), ), - contentPadding: const EdgeInsets.all(8.0), ), + contentPadding: const EdgeInsets.all(8.0), ); } diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart index 4db26ede..32700cdd 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart @@ -16,12 +16,12 @@ class SavedViewChangedDialog extends StatelessWidget { actionsOverflowButtonSpacing: 8, actions: [ const DialogCancelButton(), - TextButton( - child: Text(S.of(context)!.saveChanges), - onPressed: () { - Navigator.pop(context, false); - }, - ), + // TextButton( + // child: Text(S.of(context)!.saveChanges), + // onPressed: () { + // Navigator.pop(context, false); + // }, + // ), DialogConfirmButton( label: S.of(context)!.resetFilter, style: DialogConfirmButtonStyle.danger, diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart index 399e39f9..23026e33 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart @@ -1,11 +1,16 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; +import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; -class SavedViewChip extends StatelessWidget { +class SavedViewChip extends StatefulWidget { final SavedView view; final void Function(SavedView view) onViewSelected; - final void Function(SavedView vie) onUpdateView; + final void Function(SavedView view) onUpdateView; + final void Function(SavedView view) onDeleteView; final bool selected; final bool hasChanged; @@ -16,28 +21,146 @@ class SavedViewChip extends StatelessWidget { required this.selected, required this.hasChanged, required this.onUpdateView, + required this.onDeleteView, }); + @override + State createState() => _SavedViewChipState(); +} + +class _SavedViewChipState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _animation = _animationController.drive(Tween(begin: 0, end: 1)); + } + + bool _isExpanded = false; @override Widget build(BuildContext context) { - return Badge( - smallSize: 12, - alignment: const AlignmentDirectional(1.1, -1.2), - backgroundColor: Colors.red, - isLabelVisible: hasChanged, - child: FilterChip( - avatar: Icon( - Icons.saved_search, - color: Theme.of(context).colorScheme.onSurface, + var colorScheme = Theme.of(context).colorScheme; + final effectiveBackgroundColor = widget.selected + ? colorScheme.secondaryContainer + : colorScheme.surfaceVariant; + final effectiveForegroundColor = widget.selected + ? colorScheme.onSecondaryContainer + : colorScheme.onSurfaceVariant; + + final expandedChild = Row( + children: [ + IconButton( + padding: EdgeInsets.zero, + icon: Icon( + Icons.edit, + color: effectiveForegroundColor, + ), + onPressed: () { + EditSavedViewRoute(widget.view).push(context); + }, + ), + IconButton( + padding: EdgeInsets.zero, + icon: Icon( + Icons.delete, + color: colorScheme.error, + ), + onPressed: () async { + widget.onDeleteView(widget.view); + }, + ), + ], + ); + + return Material( + color: effectiveBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: colorScheme.outline, + ), + ), + child: InkWell( + enableFeedback: true, + borderRadius: BorderRadius.circular(8), + onTap: () => widget.onViewSelected(widget.view), + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + _buildCheckmark(effectiveForegroundColor), + _buildLabel(context, effectiveForegroundColor) + .paddedSymmetrically( + horizontal: 12, + vertical: 0, + ), + ], + ).paddedOnly(left: 8), + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: _isExpanded ? expandedChild : const SizedBox.shrink(), + ), + _buildTrailing(effectiveForegroundColor), + ], + ), ), - showCheckmark: false, - selectedColor: Theme.of(context).colorScheme.primaryContainer, - selected: selected, - label: Text(view.name), - onSelected: (_) { - onViewSelected(view); + ), + ); + } + + Widget _buildTrailing(Color effectiveForegroundColor) { + return IconButton( + icon: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Transform.rotate( + angle: _animation.value * pi, + child: Icon( + _isExpanded ? Icons.close : Icons.chevron_right, + color: effectiveForegroundColor, + ), + ); }, ), + onPressed: () { + if (_isExpanded) { + _animationController.reverse(); + } else { + _animationController.forward(); + } + setState(() { + _isExpanded = !_isExpanded; + }); + }, + ); + } + + Widget _buildLabel(BuildContext context, Color effectiveForegroundColor) { + return Text( + widget.view.name, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: effectiveForegroundColor), + ); + } + + Widget _buildCheckmark(Color effectiveForegroundColor) { + return AnimatedSize( + duration: const Duration(milliseconds: 300), + child: widget.selected + ? Icon(Icons.check, color: effectiveForegroundColor) + : const SizedBox.shrink(), ); } } diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 28c4b092..20acd058 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -1,57 +1,144 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; -class SavedViewsWidget extends StatelessWidget { +class SavedViewsWidget extends StatefulWidget { final void Function(SavedView view) onViewSelected; final void Function(SavedView view) onUpdateView; + final void Function(SavedView view) onDeleteView; + final DocumentFilter filter; + final ExpansionTileController? controller; + const SavedViewsWidget({ super.key, required this.onViewSelected, required this.filter, required this.onUpdateView, + required this.onDeleteView, + this.controller, }); + @override + State createState() => _SavedViewsWidgetState(); +} + +class _SavedViewsWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _animation = _animationController.drive(Tween(begin: 0, end: 0.5)); + } + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.only( - top: 12, - left: 16, - right: 16, - ), - height: 50, - child: BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - loaded: (savedViews) { - if (savedViews.isEmpty) { - return Text("No saved views"); - } - return ListView.builder( - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final view = savedViews.values.elementAt(index); - return SavedViewChip( - view: view, - onUpdateView: onUpdateView, - onViewSelected: onViewSelected, - selected: filter.selectedView != null && - view.id == filter.selectedView, - hasChanged: filter.selectedView == view.id && - filter != view.toDocumentFilter(), + return PageStorage( + bucket: PageStorageBucket(), + child: ExpansionTile( + controller: widget.controller, + tilePadding: const EdgeInsets.only(left: 8), + trailing: RotationTransition( + turns: _animation, + child: const Icon(Icons.expand_more), + ).paddedOnly(right: 8), + onExpansionChanged: (isExpanded) { + if (isExpanded) { + _animationController.forward(); + } else { + _animationController.reverse().then((value) => setState(() {})); + } + }, + title: Text( + S.of(context)!.views, + style: Theme.of(context).textTheme.labelLarge, + ), + leading: Icon( + Icons.saved_search, + color: Theme.of(context).colorScheme.primary, + ).padded(), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + BlocBuilder( + builder: (context, state) { + return state.map( + initial: (_) => const Placeholder(), + loading: (_) => const Placeholder(), + loaded: (value) { + if (value.savedViews.isEmpty) { + return Text(S.of(context)!.noItemsFound) + .paddedOnly(left: 16); + } + return Container( + margin: EdgeInsets.only(top: 16), + height: kMinInteractiveDimension, + child: NotificationListener( + onNotification: (notification) => true, + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + SliverList.separated( + itemBuilder: (context, index) { + final view = + value.savedViews.values.elementAt(index); + final isSelected = + (widget.filter.selectedView ?? -1) == view.id; + return SavedViewChip( + view: view, + onViewSelected: widget.onViewSelected, + selected: isSelected, + hasChanged: isSelected && + view.toDocumentFilter() != widget.filter, + onUpdateView: widget.onUpdateView, + onDeleteView: widget.onDeleteView, + ); + }, + separatorBuilder: (context, index) => + const SizedBox(width: 8), + itemCount: value.savedViews.length, + ), + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + ], + ), + ), ); }, - itemCount: savedViews.length, + error: (_) => const Placeholder(), ); }, - error: () => Text("Error loading saved views"), - orElse: () => Placeholder(), - ); - }, + ), + Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: "Create from current filter", //TODO: INTL + child: TextButton.icon( + onPressed: () { + CreateSavedViewRoute(widget.filter).push(context); + }, + icon: const Icon(Icons.add), + label: Text(S.of(context)!.newView), + ), + ).padded(4), + ), + ], ), ); } diff --git a/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart b/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart index 7aa31c96..d6aaddcb 100644 --- a/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart +++ b/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart @@ -16,7 +16,7 @@ class ConfirmDeleteSavedViewDialog extends StatelessWidget { Widget build(BuildContext context) { return AlertDialog( title: Text( - S.of(context)!.deleteView + view.name + "?", + S.of(context)!.deleteView(view.name), softWrap: true, ), content: Text(S.of(context)!.doYouReallyWantToDeleteThisView), diff --git a/lib/features/edit_label/view/impl/add_tag_page.dart b/lib/features/edit_label/view/impl/add_tag_page.dart index 70ae6382..88310b48 100644 --- a/lib/features/edit_label/view/impl/add_tag_page.dart +++ b/lib/features/edit_label/view/impl/add_tag_page.dart @@ -37,9 +37,16 @@ class AddTagPage extends StatelessWidget { .withOpacity(1.0), readOnly: true, ), - FormBuilderCheckbox( + FormBuilderField( name: Tag.isInboxTagKey, - title: Text(S.of(context)!.inboxTag), + initialValue: false, + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.inboxTag), + onChanged: (value) => field.didChange(value), + ); + }, ), ], ), diff --git a/lib/features/edit_label/view/impl/edit_tag_page.dart b/lib/features/edit_label/view/impl/edit_tag_page.dart index 48fa3bd6..c9af9bd7 100644 --- a/lib/features/edit_label/view/impl/edit_tag_page.dart +++ b/lib/features/edit_label/view/impl/edit_tag_page.dart @@ -38,10 +38,16 @@ class EditTagPage extends StatelessWidget { colorPickerType: ColorPickerType.materialPicker, readOnly: true, ), - FormBuilderCheckbox( - initialValue: tag.isInboxTag, + FormBuilderField( name: Tag.isInboxTagKey, - title: Text(S.of(context)!.inboxTag), + initialValue: tag.isInboxTag, + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.inboxTag), + onChanged: (value) => field.didChange(value), + ); + }, ), ], ), diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index d20c70bb..901e2ca1 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -137,10 +137,16 @@ class _LabelFormState extends State> { initialValue: widget.initialValue?.match, onChanged: (val) => setState(() => _errors = {}), ), - FormBuilderCheckbox( + FormBuilderField( name: Label.isInsensitiveKey, initialValue: widget.initialValue?.isInsensitive ?? true, - title: Text(S.of(context)!.caseIrrelevant), + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.caseIrrelevant), + onChanged: (value) => field.didChange(value), + ); + }, ), ...widget.additionalFields, ].padded(), diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index 7767dbf3..a41e2b6a 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -55,9 +55,17 @@ class _LandingPageState extends State { SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 0, 8), sliver: SliverToBoxAdapter( - child: Text( - "Saved Views", - style: Theme.of(context).textTheme.titleMedium, + child: Row( + children: [ + Icon( + Icons.saved_search, + color: Theme.of(context).colorScheme.primary, + ).paddedOnly(right: 8), + Text( + S.of(context)!.views, + style: Theme.of(context).textTheme.titleMedium, + ), + ], ), ), ), @@ -75,7 +83,7 @@ class _LandingPageState extends State { children: [ Text( "There are no saved views to show on your dashboard.", //TODO: INTL - ), + ).padded(), TextButton.icon( onPressed: () {}, icon: const Icon(Icons.add), @@ -89,6 +97,7 @@ class _LandingPageState extends State { itemBuilder: (context, index) { return SavedViewPreview( savedView: dashboardViews.elementAt(index), + expanded: index == 0, ); }, itemCount: dashboardViews.length, diff --git a/lib/features/landing/view/widgets/expansion_card.dart b/lib/features/landing/view/widgets/expansion_card.dart index 21695e5d..f27affd9 100644 --- a/lib/features/landing/view/widgets/expansion_card.dart +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -29,7 +29,7 @@ class ExpansionCard extends StatelessWidget { ), ), child: ExpansionTile( - backgroundColor: Theme.of(context).colorScheme.surface, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, initiallyExpanded: initiallyExpanded, title: title, children: [content], diff --git a/lib/features/saved_view/view/add_saved_view_page.dart b/lib/features/saved_view/view/add_saved_view_page.dart index 07a6613c..d368ed7c 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +const _fkName = 'name'; +const _fkShowOnDashboard = 'show_on_dashboard'; +const _fkShowInSidebar = 'show_in_sidebar'; + class AddSavedViewPage extends StatefulWidget { final DocumentFilter? initialFilter; const AddSavedViewPage({ @@ -17,12 +23,7 @@ class AddSavedViewPage extends StatefulWidget { } class _AddSavedViewPageState extends State { - static const fkName = 'name'; - static const fkShowOnDashboard = 'show_on_dashboard'; - static const fkShowInSidebar = 'show_in_sidebar'; - final _savedViewFormKey = GlobalKey(); - final _filterFormKey = GlobalKey(); @override Widget build(BuildContext context) { return Scaffold( @@ -46,7 +47,7 @@ class _AddSavedViewPageState extends State { child: Column( children: [ FormBuilderTextField( - name: _AddSavedViewPageState.fkName, + name: _fkName, validator: (value) { if (value?.trim().isEmpty ?? true) { return S.of(context)!.thisFieldIsRequired; @@ -57,41 +58,53 @@ class _AddSavedViewPageState extends State { label: Text(S.of(context)!.name), ), ), - FormBuilderCheckbox( - name: _AddSavedViewPageState.fkShowOnDashboard, + FormBuilderField( + name: _fkShowOnDashboard, initialValue: false, - title: Text(S.of(context)!.showOnDashboard), + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.showOnDashboard), + onChanged: (value) => field.didChange(value), + ); + }, ), - FormBuilderCheckbox( - name: _AddSavedViewPageState.fkShowInSidebar, + FormBuilderField( + name: _fkShowInSidebar, initialValue: false, - title: Text(S.of(context)!.showInSidebar), + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.showInSidebar), + onChanged: (value) => field.didChange(value), + ); + }, ), ], ), ), - const Divider(), ], ), ), ); } - void _onCreate(BuildContext context) { + void _onCreate(BuildContext context) async { if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { - // context.pop( - // SavedView.fromDocumentFilter( - // DocumentFilterForm.assembleFilter( - // _filterFormKey, - // widget.currentFilter, - // ), - // name: _savedViewFormKey.currentState?.value[fkName] as String, - // showOnDashboard: - // _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool, - // showInSidebar: - // _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool, - // ), - // ); + final cubit = context.read(); + var savedView = SavedView.fromDocumentFilter( + widget.initialFilter ?? const DocumentFilter(), + name: _savedViewFormKey.currentState?.value[_fkName] as String, + showOnDashboard: + _savedViewFormKey.currentState?.value[_fkShowOnDashboard] as bool, + showInSidebar: + _savedViewFormKey.currentState?.value[_fkShowInSidebar] as bool, + ); + final router = GoRouter.of(context); + await cubit.add( + savedView, + ); + router.pop(); } } } diff --git a/lib/features/saved_view/view/edit_saved_view_page.dart b/lib/features/saved_view/view/edit_saved_view_page.dart new file mode 100644 index 00000000..3ad1d06e --- /dev/null +++ b/lib/features/saved_view/view/edit_saved_view_page.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; + +const _fkName = 'name'; +const _fkShowOnDashboard = 'show_on_dashboard'; +const _fkShowInSidebar = 'show_in_sidebar'; + +class EditSavedViewPage extends StatefulWidget { + final SavedView savedView; + const EditSavedViewPage({ + super.key, + required this.savedView, + }); + + @override + State createState() => _EditSavedViewPageState(); +} + +class _EditSavedViewPageState extends State { + final _savedViewFormKey = GlobalKey(); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context)!.editView), + ), + floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_edit_saved_view_page", + icon: const Icon(Icons.save), + onPressed: () => _onCreate(context), + label: Text(S.of(context)!.saveChanges), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FormBuilder( + key: _savedViewFormKey, + child: Column( + children: [ + FormBuilderTextField( + initialValue: widget.savedView.name, + name: _fkName, + validator: (value) { + if (value?.trim().isEmpty ?? true) { + return S.of(context)!.thisFieldIsRequired; + } + return null; + }, + decoration: InputDecoration( + label: Text(S.of(context)!.name), + ), + ), + FormBuilderField( + name: _fkShowOnDashboard, + initialValue: widget.savedView.showOnDashboard, + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.showOnDashboard), + onChanged: (value) => field.didChange(value), + ); + }, + ), + FormBuilderField( + name: _fkShowInSidebar, + initialValue: widget.savedView.showInSidebar, + builder: (field) { + return CheckboxListTile( + value: field.value, + title: Text(S.of(context)!.showInSidebar), + onChanged: (value) => field.didChange(value), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _onCreate(BuildContext context) async { + if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { + final cubit = context.read(); + var savedView = widget.savedView.copyWith( + name: _savedViewFormKey.currentState!.value[_fkName], + showInSidebar: _savedViewFormKey.currentState!.value[_fkShowInSidebar], + showOnDashboard: + _savedViewFormKey.currentState!.value[_fkShowOnDashboard], + ); + final router = GoRouter.of(context); + await cubit.update(savedView); + router.pop(); + } + } +} diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 6dac4af1..638855c7 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -12,9 +12,11 @@ import 'package:provider/provider.dart'; class SavedViewPreview extends StatelessWidget { final SavedView savedView; + final bool expanded; const SavedViewPreview({ super.key, required this.savedView, + required this.expanded, }); @override @@ -24,7 +26,7 @@ class SavedViewPreview extends StatelessWidget { SavedViewPreviewCubit(context.read(), savedView)..initialize(), builder: (context, child) { return ExpansionCard( - initiallyExpanded: true, + initiallyExpanded: expanded, title: Text(savedView.name), content: BlocBuilder( builder: (context, state) { @@ -33,7 +35,7 @@ class SavedViewPreview extends StatelessWidget { return Column( children: [ if (documents.isEmpty) - Text("This view is empty.").padded() + Text("This view does not match any documents.").padded() else for (final document in documents) DocumentListItem( @@ -45,6 +47,7 @@ class SavedViewPreview extends StatelessWidget { DocumentDetailsRoute($extra: document) .push(context); }, + onSelected: null, ), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -64,7 +67,8 @@ class SavedViewPreview extends StatelessWidget { ], ); }, - error: () => const Text("Error loading preview"), //TODO: INTL + error: () => + const Text("Could not load saved view."), //TODO: INTL orElse: () => const Padding( padding: EdgeInsets.all(8.0), child: Center(child: CircularProgressIndicator()), diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index 6d93c08b..c537abd3 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -15,9 +15,10 @@ class _LanguageSelectionSettingState extends State { static const _languageOptions = { 'en': LanguageOption('English', true), 'de': LanguageOption('Deutsch', true), + 'es': LanguageOption("Español", true), + 'fr': LanguageOption('Français', true), 'cs': LanguageOption('Česky', true), 'tr': LanguageOption('Türkçe', true), - 'fr': LanguageOption('Français', true), 'pl': LanguageOption('Polska', true), 'ca': LanguageOption('Catalan', true), }; diff --git a/lib/helpers/message_helpers.dart b/lib/helpers/message_helpers.dart index 0ac415a7..b3020fda 100644 --- a/lib/helpers/message_helpers.dart +++ b/lib/helpers/message_helpers.dart @@ -25,6 +25,7 @@ void showSnackBar( ..hideCurrentSnackBar() ..showSnackBar( SnackBar( + behavior: SnackBarBehavior.floating, content: (details != null) ? RichText( maxLines: 5, diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 21c9ec42..c9eb7b57 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -861,8 +861,20 @@ "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." }, - "missingPermissions": "You do not have the necessary permissions to perform this action.", + "missingPermissions": "No tens els permisos necessaris per a completar l'acció.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donate", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 122eec31..4b7caf50 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donate", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 5aac1d6a..f2af3580 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -864,5 +864,17 @@ "missingPermissions": "Sie besitzen nicht die benötigten Berechtigungen, um diese Aktion durchzuführen.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Ansicht bearbeiten", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Spenden", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Vielen Dank, dass Du diese App unterstützen möchtest! Aufgrund der Zahlungsrichtlinien von Google und Apple dürfen keine Links, die zu Spendenseiten führen, in der App angezeigt werden. Nicht einmal die Verlinkung zur Repository-Seite des Projekts scheint in diesem Zusammenhang erlaubt zu sein. Werfe von daher vielleicht einen Blick auf den Abschnitt 'Donations' in der README des Projekts. Deine Unterstützung ist sehr willkommen und hält die Entwicklung dieser App am Leben. Vielen Dank!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 0a941ba6..5a397fc0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donate", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb new file mode 100644 index 00000000..42abaa2c --- /dev/null +++ b/lib/l10n/intl_es.arb @@ -0,0 +1,880 @@ +{ + "developedBy": "Desarrollado por {name}.", + "@developedBy": { + "placeholders": { + "name": {} + } + }, + "addAnotherAccount": "Añadir otra cuenta", + "@addAnotherAccount": {}, + "account": "Cuenta", + "@account": {}, + "addCorrespondent": "Nuevo interlocutor", + "@addCorrespondent": { + "description": "Title when adding a new correspondent" + }, + "addDocumentType": "Nuevo tipo de documento", + "@addDocumentType": { + "description": "Title when adding a new document type" + }, + "addStoragePath": "Nueva ruta de almacenamiento", + "@addStoragePath": { + "description": "Title when adding a new storage path" + }, + "addTag": "Nueva Etiqueta", + "@addTag": { + "description": "Title when adding a new tag" + }, + "aboutThisApp": "Sobre esta app", + "@aboutThisApp": { + "description": "Label for about this app tile displayed in the drawer" + }, + "loggedInAs": "Conectado como {name}", + "@loggedInAs": { + "placeholders": { + "name": {} + } + }, + "disconnect": "Desconectar", + "@disconnect": { + "description": "Logout button label" + }, + "reportABug": "Reportar un problema", + "@reportABug": {}, + "settings": "Ajustes", + "@settings": {}, + "authenticateOnAppStart": "Autenticar al iniciar la aplicación", + "@authenticateOnAppStart": { + "description": "Description of the biometric authentication settings tile" + }, + "biometricAuthentication": "Autenticación biométrica", + "@biometricAuthentication": {}, + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Autenticar para habilitar la autenticación biométrica} disable{Autenticar para deshabilitar la autenticación biométrica} other{}}", + "@authenticateToToggleBiometricAuthentication": { + "placeholders": { + "mode": {} + } + }, + "documents": "Documentos", + "@documents": {}, + "inbox": "Buzón", + "@inbox": {}, + "labels": "Etiquetas", + "@labels": {}, + "scanner": "Escáner", + "@scanner": {}, + "startTyping": "Empezar a escribir...", + "@startTyping": {}, + "doYouReallyWantToDeleteThisView": "¿Realmente desea eliminar esta vista?", + "@doYouReallyWantToDeleteThisView": {}, + "deleteView": "¿Eliminar vista {name}?", + "@deleteView": {}, + "addedAt": "Añadido En", + "@addedAt": {}, + "archiveSerialNumber": "Número de serie del archivo", + "@archiveSerialNumber": {}, + "asn": "NSA", + "@asn": {}, + "correspondent": "Interlocutor", + "@correspondent": {}, + "createdAt": "Creado en", + "@createdAt": {}, + "documentSuccessfullyDeleted": "Documento eliminado correctamente.", + "@documentSuccessfullyDeleted": {}, + "assignAsn": "Asignar NSA", + "@assignAsn": {}, + "deleteDocumentTooltip": "Eliminar", + "@deleteDocumentTooltip": { + "description": "Tooltip shown for the delete button on details page" + }, + "downloadDocumentTooltip": "Descargar", + "@downloadDocumentTooltip": { + "description": "Tooltip shown for the download button on details page" + }, + "editDocumentTooltip": "Editar", + "@editDocumentTooltip": { + "description": "Tooltip shown for the edit button on details page" + }, + "loadFullContent": "Cargar el contenido completo", + "@loadFullContent": {}, + "noAppToDisplayPDFFilesFound": "¡No se encontraron aplicaciones para mostrar archivos PDF!", + "@noAppToDisplayPDFFilesFound": {}, + "openInSystemViewer": "Abrir en el visor del sistema", + "@openInSystemViewer": {}, + "couldNotOpenFilePermissionDenied": "No se pudo abrir el archivo: Permiso denegado.", + "@couldNotOpenFilePermissionDenied": {}, + "previewTooltip": "Vista previa", + "@previewTooltip": { + "description": "Tooltip shown for the preview button on details page" + }, + "shareTooltip": "Compartir", + "@shareTooltip": { + "description": "Tooltip shown for the share button on details page" + }, + "similarDocuments": "Documentos similares", + "@similarDocuments": { + "description": "Label shown in the tabbar on details page" + }, + "content": "Contenido", + "@content": { + "description": "Label shown in the tabbar on details page" + }, + "metaData": "Metadatos", + "@metaData": { + "description": "Label shown in the tabbar on details page" + }, + "overview": "Vista general", + "@overview": { + "description": "Label shown in the tabbar on details page" + }, + "documentType": "Tipo de Documento", + "@documentType": {}, + "archivedPdf": "Archivado (pdf)", + "@archivedPdf": { + "description": "Option to chose when downloading a document" + }, + "chooseFiletype": "Elegir tipo de archivo", + "@chooseFiletype": {}, + "original": "Original", + "@original": { + "description": "Option to chose when downloading a document" + }, + "documentSuccessfullyDownloaded": "Documento descargado correctamente.", + "@documentSuccessfullyDownloaded": {}, + "suggestions": "Sugerencias: ", + "@suggestions": {}, + "editDocument": "Editar Documento", + "@editDocument": {}, + "advanced": "Avanzado", + "@advanced": {}, + "apply": "Aplicar", + "@apply": {}, + "extended": "Extendido", + "@extended": {}, + "titleAndContent": "Título y Contenido", + "@titleAndContent": {}, + "title": "Título", + "@title": {}, + "reset": "Restablecer", + "@reset": {}, + "filterDocuments": "Filtrar Documentos", + "@filterDocuments": { + "description": "Title of the document filter" + }, + "originalMD5Checksum": "Verificación MD5", + "@originalMD5Checksum": {}, + "mediaFilename": "Nombre del archivo", + "@mediaFilename": {}, + "originalFileSize": "Tamaño del archivo original", + "@originalFileSize": {}, + "originalMIMEType": "Tipo MIME Original", + "@originalMIMEType": {}, + "modifiedAt": "Modificado en", + "@modifiedAt": {}, + "preview": "Vista previa", + "@preview": { + "description": "Title of the document preview page" + }, + "scanADocument": "Escanear documento", + "@scanADocument": {}, + "noDocumentsScannedYet": "No hay documentos escaneados.", + "@noDocumentsScannedYet": {}, + "or": "o", + "@or": { + "description": "Used on the scanner page between both main actions when no scans have been captured." + }, + "deleteAllScans": "Eliminar todos los escaneos", + "@deleteAllScans": {}, + "uploadADocumentFromThisDevice": "Subir un documento desde este dispositivo", + "@uploadADocumentFromThisDevice": { + "description": "Button label on scanner page" + }, + "noMatchesFound": "No se encontraron documentos.", + "@noMatchesFound": { + "description": "Displayed when no documents were found in the document search." + }, + "removeFromSearchHistory": "¿Eliminar del historial de búsqueda?", + "@removeFromSearchHistory": {}, + "results": "Resultados", + "@results": { + "description": "Label displayed above search results in document search." + }, + "searchDocuments": "Buscar documentos", + "@searchDocuments": {}, + "resetFilter": "Limpiar filtro", + "@resetFilter": {}, + "lastMonth": "Último Mes", + "@lastMonth": {}, + "last7Days": "Últimos 7 días", + "@last7Days": {}, + "last3Months": "Últimos 3 meses", + "@last3Months": {}, + "lastYear": "Último año", + "@lastYear": {}, + "search": "Buscar", + "@search": {}, + "documentsSuccessfullyDeleted": "Documentos eliminados correctamente.", + "@documentsSuccessfullyDeleted": {}, + "thereSeemsToBeNothingHere": "Parece que no hay nada aquí...", + "@thereSeemsToBeNothingHere": {}, + "oops": "Ups.", + "@oops": {}, + "newDocumentAvailable": "¡Nuevo documento disponible!", + "@newDocumentAvailable": {}, + "orderBy": "Ordenar por", + "@orderBy": {}, + "thisActionIsIrreversibleDoYouWishToProceedAnyway": "Esta acción es irreversible. ¿Desea continuar?", + "@thisActionIsIrreversibleDoYouWishToProceedAnyway": {}, + "confirmDeletion": "Confirmar eliminación", + "@confirmDeletion": {}, + "areYouSureYouWantToDeleteTheFollowingDocuments": "{count, plural, one{¿Está seguro de querer eliminar el siguiente documento?} other{¿Está seguro de querer eliminar los siguientes documentos?}}", + "@areYouSureYouWantToDeleteTheFollowingDocuments": { + "placeholders": { + "count": {} + } + }, + "countSelected": "{count} en selección", + "@countSelected": { + "description": "Displayed in the appbar when at least one document is selected.", + "placeholders": { + "count": {} + } + }, + "storagePath": "Ruta de Almacenamiento", + "@storagePath": {}, + "prepareDocument": "Preparar documento", + "@prepareDocument": {}, + "tags": "Etiquetas", + "@tags": {}, + "documentSuccessfullyUpdated": "Documento actualizado correctamente.", + "@documentSuccessfullyUpdated": {}, + "fileName": "Nombre del archivo", + "@fileName": {}, + "synchronizeTitleAndFilename": "Sincronizar título y nombre del archivo", + "@synchronizeTitleAndFilename": {}, + "reload": "Actualizar", + "@reload": {}, + "documentSuccessfullyUploadedProcessing": "Documento subido correctamente, procesando...", + "@documentSuccessfullyUploadedProcessing": {}, + "deleteLabelWarningText": "Esta etiqueta contiene referencias a otros documentos. Al eliminar esta etiqueta, todas las referencias serán eliminadas. ¿Desea continuar?", + "@deleteLabelWarningText": {}, + "couldNotAcknowledgeTasks": "No se han podido reconocer las tareas.", + "@couldNotAcknowledgeTasks": {}, + "authenticationFailedPleaseTryAgain": "Error de autenticación, intente nuevamente.", + "@authenticationFailedPleaseTryAgain": {}, + "anErrorOccurredWhileTryingToAutocompleteYourQuery": "Ha ocurrido un error intentando completar su búsqueda.", + "@anErrorOccurredWhileTryingToAutocompleteYourQuery": {}, + "biometricAuthenticationFailed": "Falló la autenticación biométrica.", + "@biometricAuthenticationFailed": {}, + "biometricAuthenticationNotSupported": "La autenticación biométrica no es compatible con este dispositivo.", + "@biometricAuthenticationNotSupported": {}, + "couldNotBulkEditDocuments": "No se han podido editar masivamente los documentos.", + "@couldNotBulkEditDocuments": {}, + "couldNotCreateCorrespondent": "No se ha podido crear el interlocutor, intente nuevamente.", + "@couldNotCreateCorrespondent": {}, + "couldNotLoadCorrespondents": "No se han podido cargar interlocutores.", + "@couldNotLoadCorrespondents": {}, + "couldNotCreateSavedView": "No se ha podido guardar la vista, intente nuevamente.", + "@couldNotCreateSavedView": {}, + "couldNotDeleteSavedView": "No se ha podido eliminar la vista, intente nuevamente", + "@couldNotDeleteSavedView": {}, + "youAreCurrentlyOffline": "Estás desconectado. Asegúrate de estar conectado a internet.", + "@youAreCurrentlyOffline": {}, + "couldNotAssignArchiveSerialNumber": "No se pudo asignar número de serie al archivo.", + "@couldNotAssignArchiveSerialNumber": {}, + "couldNotDeleteDocument": "No se ha podido eliminar el documento, intente nuevamente.", + "@couldNotDeleteDocument": {}, + "couldNotLoadDocuments": "No se han podido cargar los documentos, intente nuevamente.", + "@couldNotLoadDocuments": {}, + "couldNotLoadDocumentPreview": "No se ha podido cargar la vista previa del documento.", + "@couldNotLoadDocumentPreview": {}, + "couldNotCreateDocument": "No se ha podido crear el documento, intente nuevamente.", + "@couldNotCreateDocument": {}, + "couldNotLoadDocumentTypes": "No se han podido cargar los tipos de documento, intente nuevamente.", + "@couldNotLoadDocumentTypes": {}, + "couldNotUpdateDocument": "No se ha podido actualizar el documento, intente nuevamente.", + "@couldNotUpdateDocument": {}, + "couldNotUploadDocument": "No se ha podido subir el documento, intente nuevamente.", + "@couldNotUploadDocument": {}, + "invalidCertificateOrMissingPassphrase": "Certificado inválido o falta la frase de seguridad, intente nuevamente", + "@invalidCertificateOrMissingPassphrase": {}, + "couldNotLoadSavedViews": "No se han podido cargar las vistas guardadas.", + "@couldNotLoadSavedViews": {}, + "aClientCertificateWasExpectedButNotSent": "Se esperaba un certificado de cliente pero no se ha enviado. Proporcione un certificado de cliente válido.", + "@aClientCertificateWasExpectedButNotSent": {}, + "userIsNotAuthenticated": "Usuario no autenticado.", + "@userIsNotAuthenticated": {}, + "requestTimedOut": "La petición al servidor ha superado el tiempo de espera.", + "@requestTimedOut": {}, + "anErrorOccurredRemovingTheScans": "Ha ocurrido un error eliminando los escaneos.", + "@anErrorOccurredRemovingTheScans": {}, + "couldNotReachYourPaperlessServer": "No se ha podido conectar con el servidor de Paperless, ¿Está funcionando?", + "@couldNotReachYourPaperlessServer": {}, + "couldNotLoadSimilarDocuments": "No se han podido cargar documentos similares.", + "@couldNotLoadSimilarDocuments": {}, + "couldNotCreateStoragePath": "No se ha podido crear la ruta de almacenamiento, intente nuevamente.", + "@couldNotCreateStoragePath": {}, + "couldNotLoadStoragePaths": "No se han podido cargar las rutas de almacenamiento.", + "@couldNotLoadStoragePaths": {}, + "couldNotLoadSuggestions": "No se han podido cargar sugerencias.", + "@couldNotLoadSuggestions": {}, + "couldNotCreateTag": "No se ha podido crear la etiqueta, intente nuevamente.", + "@couldNotCreateTag": {}, + "couldNotLoadTags": "No se han podido cargar las etiquetas.", + "@couldNotLoadTags": {}, + "anUnknownErrorOccurred": "Ocurrió un error desconocido.", + "@anUnknownErrorOccurred": {}, + "fileFormatNotSupported": "Formato de archivo no compatible.", + "@fileFormatNotSupported": {}, + "report": "INFORMAR", + "@report": {}, + "absolute": "Absoluto", + "@absolute": {}, + "hintYouCanAlsoSpecifyRelativeValues": "Consejo: Además de fechas concretas, puedes especificar un intervalo de tiempo relativo a la fecha actual.", + "@hintYouCanAlsoSpecifyRelativeValues": { + "description": "Displayed in the extended date range picker" + }, + "amount": "Cantidad", + "@amount": {}, + "relative": "Relativo", + "@relative": {}, + "last": "Último", + "@last": {}, + "timeUnit": "Unidad de tiempo", + "@timeUnit": {}, + "selectDateRange": "Seleccione el intervalo de fechas", + "@selectDateRange": {}, + "after": "Después", + "@after": {}, + "before": "Antes", + "@before": {}, + "days": "{count, plural, one{día} other{días}}", + "@days": { + "placeholders": { + "count": {} + } + }, + "lastNDays": "{count, plural, one{Ayer} other{Últimos {count} días}}", + "@lastNDays": { + "placeholders": { + "count": {} + } + }, + "lastNMonths": "{count, plural, one{Último mes} other{Últimos {count} meses}}", + "@lastNMonths": { + "placeholders": { + "count": {} + } + }, + "lastNWeeks": "{count, plural, one{Última semana} other{Últimas {count} semanas}}", + "@lastNWeeks": { + "placeholders": { + "count": {} + } + }, + "lastNYears": "{count, plural, one{Último año} other{Últimos {count} años}}", + "@lastNYears": { + "placeholders": { + "count": {} + } + }, + "months": "{count, plural, one{mes} other{meses}}", + "@months": { + "placeholders": { + "count": {} + } + }, + "weeks": "{count, plural, one{semana} other{semanas}}", + "@weeks": { + "placeholders": { + "count": {} + } + }, + "years": "{count, plural, one{año} other{años}}", + "@years": { + "placeholders": { + "count": {} + } + }, + "gotIt": "¡Entendido!", + "@gotIt": {}, + "cancel": "Cancelar", + "@cancel": {}, + "close": "Cerrar", + "@close": {}, + "create": "Crear", + "@create": {}, + "delete": "Eliminar", + "@delete": {}, + "edit": "Editar", + "@edit": {}, + "ok": "Aceptar", + "@ok": {}, + "save": "Guardar", + "@save": {}, + "select": "Seleccionar", + "@select": {}, + "saveChanges": "Guardar cambios", + "@saveChanges": {}, + "upload": "Subir", + "@upload": {}, + "youreOffline": "Estás desconectado.", + "@youreOffline": {}, + "deleteDocument": "Eliminar documento", + "@deleteDocument": { + "description": "Used as an action label on each inbox item" + }, + "removeDocumentFromInbox": "Documento eliminado del buzón.", + "@removeDocumentFromInbox": {}, + "areYouSureYouWantToMarkAllDocumentsAsSeen": "¿Está seguro de marcar todos los documentos como leídos? Esto realizará una edición masiva que eliminará todas las etiquetas de entrada de los documentos. ¡Esta acción no es reversible! ¿Desea continuar?", + "@areYouSureYouWantToMarkAllDocumentsAsSeen": {}, + "markAllAsSeen": "¿Marcar todos como leídos?", + "@markAllAsSeen": {}, + "allSeen": "Todos leídos", + "@allSeen": {}, + "markAsSeen": "Marcar como leído", + "@markAsSeen": {}, + "refresh": "Recargar", + "@refresh": {}, + "youDoNotHaveUnseenDocuments": "No tienes documentos no leídos.", + "@youDoNotHaveUnseenDocuments": {}, + "quickAction": "Acción rápida", + "@quickAction": {}, + "suggestionSuccessfullyApplied": "Sugerencia aplicada correctamente.", + "@suggestionSuccessfullyApplied": {}, + "today": "Hoy", + "@today": {}, + "undo": "Deshacer", + "@undo": {}, + "nUnseen": "{count} no leídos", + "@nUnseen": { + "placeholders": { + "count": {} + } + }, + "swipeLeftToMarkADocumentAsSeen": "Consejo: Deslice a la izquierda para marcar un documento como leído y elimina todas la etiquetas de entrada del documento.", + "@swipeLeftToMarkADocumentAsSeen": {}, + "yesterday": "Ayer", + "@yesterday": {}, + "anyAssigned": "Cualquier asignado", + "@anyAssigned": {}, + "noItemsFound": "¡No se han encontrado elementos!", + "@noItemsFound": {}, + "caseIrrelevant": "Sin distinción mayúscula/minúscula", + "@caseIrrelevant": {}, + "matchingAlgorithm": "Algoritmo de coincidencia", + "@matchingAlgorithm": {}, + "match": "Coincidencia", + "@match": {}, + "name": "Nombre", + "@name": {}, + "notAssigned": "Sin asignar", + "@notAssigned": {}, + "addNewCorrespondent": "Añadir nuevo interlocutor", + "@addNewCorrespondent": {}, + "noCorrespondentsSetUp": "Parece que no tienes ningún interlocutor configurado.", + "@noCorrespondentsSetUp": {}, + "correspondents": "Interlocutores", + "@correspondents": {}, + "addNewDocumentType": "Añadir nuevo tipo de documento", + "@addNewDocumentType": {}, + "noDocumentTypesSetUp": "Parece que no tienes ningún tipo de documento configurado.", + "@noDocumentTypesSetUp": {}, + "documentTypes": "Tipos de Documentos", + "@documentTypes": {}, + "addNewStoragePath": "Agregar nueva ruta de almacenamiento", + "@addNewStoragePath": {}, + "noStoragePathsSetUp": "Parece que no tienes ninguna ruta de almacenamiento configurada.", + "@noStoragePathsSetUp": {}, + "storagePaths": "Rutas de Almacenamiento", + "@storagePaths": {}, + "addNewTag": "Agregar nueva etiqueta", + "@addNewTag": {}, + "noTagsSetUp": "Parece que no tienes ninguna etiqueta configurada.", + "@noTagsSetUp": {}, + "linkedDocuments": "Documentos vinculados", + "@linkedDocuments": {}, + "advancedSettings": "Ajustes Avanzados", + "@advancedSettings": {}, + "passphrase": "Frase de seguridad", + "@passphrase": {}, + "configureMutualTLSAuthentication": "Configurar Autenticación Mutua TLS", + "@configureMutualTLSAuthentication": {}, + "invalidCertificateFormat": "Formato de certificado inválido, solo se permite .pfx", + "@invalidCertificateFormat": {}, + "clientcertificate": "Certificado de Cliente", + "@clientcertificate": {}, + "selectFile": "Seleccionar archivo...", + "@selectFile": {}, + "continueLabel": "Continuar", + "@continueLabel": {}, + "incorrectOrMissingCertificatePassphrase": "Frase de seguridad del certificado no encontrada o incorrecta.", + "@incorrectOrMissingCertificatePassphrase": {}, + "connect": "Conectar", + "@connect": {}, + "password": "Contraseña", + "@password": {}, + "passwordMustNotBeEmpty": "La contraseña no debe estar vacía.", + "@passwordMustNotBeEmpty": {}, + "connectionTimedOut": "Tiempo de conexión agotado.", + "@connectionTimedOut": {}, + "loginPageReachabilityMissingClientCertificateText": "Se esperaba un certificado de cliente pero no se ha enviado. Proporcione un certificado de cliente válido.", + "@loginPageReachabilityMissingClientCertificateText": {}, + "couldNotEstablishConnectionToTheServer": "No se ha podido establecer una conexión con el servidor.", + "@couldNotEstablishConnectionToTheServer": {}, + "connectionSuccessfulylEstablished": "La conexión se ha establecido correctamente.", + "@connectionSuccessfulylEstablished": {}, + "hostCouldNotBeResolved": "El host no pudo ser resuelto. Por favor, compruebe la dirección del servidor y su conexión a Internet. ", + "@hostCouldNotBeResolved": {}, + "serverAddress": "Dirección del servidor", + "@serverAddress": {}, + "invalidAddress": "Dirección inválida.", + "@invalidAddress": {}, + "serverAddressMustIncludeAScheme": "La dirección del servidor debe incluir un esquema.", + "@serverAddressMustIncludeAScheme": {}, + "serverAddressMustNotBeEmpty": "La dirección del servidor no puede estar vacía.", + "@serverAddressMustNotBeEmpty": {}, + "signIn": "Iniciar sesión", + "@signIn": {}, + "loginPageSignInTitle": "Iniciar sesión", + "@loginPageSignInTitle": {}, + "signInToServer": "Iniciar sesión en {serverAddress}", + "@signInToServer": { + "placeholders": { + "serverAddress": {} + } + }, + "connectToPaperless": "Conectar a Paperless", + "@connectToPaperless": {}, + "username": "Usuario", + "@username": {}, + "usernameMustNotBeEmpty": "Usuario no puede estar vacío.", + "@usernameMustNotBeEmpty": {}, + "documentContainsAllOfTheseWords": "El documento contiene todas estas palabras", + "@documentContainsAllOfTheseWords": {}, + "all": "Todo", + "@all": {}, + "documentContainsAnyOfTheseWords": "El documento contiene cualquiera de estas palabras", + "@documentContainsAnyOfTheseWords": {}, + "any": "Cualquiera", + "@any": {}, + "learnMatchingAutomatically": "Aprendizaje automático", + "@learnMatchingAutomatically": {}, + "auto": "Auto", + "@auto": {}, + "documentContainsThisString": "El documento contiene este texto", + "@documentContainsThisString": {}, + "exact": "Exacto", + "@exact": {}, + "documentContainsAWordSimilarToThisWord": "El documento contiene una palabra similar a esta", + "@documentContainsAWordSimilarToThisWord": {}, + "fuzzy": "Similar", + "@fuzzy": {}, + "documentMatchesThisRegularExpression": "El documento coincide con la expresión regular", + "@documentMatchesThisRegularExpression": {}, + "regularExpression": "Expresión regular", + "@regularExpression": {}, + "anInternetConnectionCouldNotBeEstablished": "No se ha podido establecer una conexión a internet.", + "@anInternetConnectionCouldNotBeEstablished": {}, + "done": "Hecho", + "@done": {}, + "next": "Siguiente", + "@next": {}, + "couldNotAccessReceivedFile": "No se ha podido acceder al archivo recibido. Intente abrir la app antes de compartir.", + "@couldNotAccessReceivedFile": {}, + "newView": "Nueva vista", + "@newView": {}, + "createsASavedViewBasedOnTheCurrentFilterCriteria": "Crea una nueva vista basada en el actual criterio de filtrado.", + "@createsASavedViewBasedOnTheCurrentFilterCriteria": {}, + "createViewsToQuicklyFilterYourDocuments": "Crea vistas para filtrar rápidamente tus documentos.", + "@createViewsToQuicklyFilterYourDocuments": {}, + "nFiltersSet": "{count, plural, one{{count} filtro aplicado} other{{count} filtros aplicados}}", + "@nFiltersSet": { + "placeholders": { + "count": {} + } + }, + "showInSidebar": "Mostrar en la barra lateral", + "@showInSidebar": {}, + "showOnDashboard": "Mostrar en el panel", + "@showOnDashboard": {}, + "views": "Vistas", + "@views": {}, + "clearAll": "Limpiar todo", + "@clearAll": {}, + "scan": "Escanear", + "@scan": {}, + "previewScan": "Vista previa", + "@previewScan": {}, + "scrollToTop": "Volver arriba", + "@scrollToTop": {}, + "paperlessServerVersion": "Versión del servidor Paperless", + "@paperlessServerVersion": {}, + "darkTheme": "Tema Oscuro", + "@darkTheme": {}, + "lightTheme": "Tema Claro", + "@lightTheme": {}, + "systemTheme": "Usar tema del sistema", + "@systemTheme": {}, + "appearance": "Apariencia", + "@appearance": {}, + "languageAndVisualAppearance": "Idioma y apariencia visual", + "@languageAndVisualAppearance": {}, + "applicationSettings": "Aplicación", + "@applicationSettings": {}, + "colorSchemeHint": "Elija entre un esquema de colores clásicos, inspirado en el verde tradicional de Paperless, o utilice un esquema de color dinámico, basado en el tema del sistema.", + "@colorSchemeHint": {}, + "colorSchemeNotSupportedWarning": "El tema dinámico solamente es compatible con dispositivos con Android 12 o superior. Seleccionar la opción 'Dinámico' podría no tener efecto dependiendo de la implementación en su sistema operativo.", + "@colorSchemeNotSupportedWarning": {}, + "colors": "Colores", + "@colors": {}, + "language": "Idioma", + "@language": {}, + "security": "Seguridad", + "@security": {}, + "mangeFilesAndStorageSpace": "Administre los archivos y el espacio de almacenamiento", + "@mangeFilesAndStorageSpace": {}, + "storage": "Almacenamiento", + "@storage": {}, + "dark": "Oscuro", + "@dark": {}, + "light": "Claro", + "@light": {}, + "system": "Sistema", + "@system": {}, + "ascending": "Ascendente", + "@ascending": {}, + "descending": "Descendente", + "@descending": {}, + "storagePathDay": "día", + "@storagePathDay": {}, + "storagePathMonth": "mes", + "@storagePathMonth": {}, + "storagePathYear": "año", + "@storagePathYear": {}, + "color": "Color", + "@color": {}, + "filterTags": "Filtrar etiquetas...", + "@filterTags": {}, + "inboxTag": "Etiqueta de entrada", + "@inboxTag": {}, + "uploadInferValuesHint": "Si especifica valores en estos campos, su instancia de Paperless no obtendrá un valor automáticamente. Deje estos campos en blanco si quiere que estos valores sean completados por el servidor.", + "@uploadInferValuesHint": {}, + "useTheConfiguredBiometricFactorToAuthenticate": "Usar el factor biométrico configurado para autenticar y desbloquear sus documentos.", + "@useTheConfiguredBiometricFactorToAuthenticate": {}, + "verifyYourIdentity": "Verifica tu identidad", + "@verifyYourIdentity": {}, + "verifyIdentity": "Verificar identidad", + "@verifyIdentity": {}, + "detailed": "Detallado", + "@detailed": {}, + "grid": "Cuadrícula", + "@grid": {}, + "list": "Lista", + "@list": {}, + "remove": "Eliminar", + "removeQueryFromSearchHistory": "¿Eliminar consulta del historial de búsqueda?", + "dynamicColorScheme": "Dinámico", + "@dynamicColorScheme": {}, + "classicColorScheme": "Clásico", + "@classicColorScheme": {}, + "notificationDownloadComplete": "Descarga completada", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Descargando documento", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Número de serie del archivo actualizado.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Invítame a un café", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + }, + "thisFieldIsRequired": "¡Este campo es obligatorio!", + "@thisFieldIsRequired": { + "description": "Message shown below the form field when a required field has not been filled out." + }, + "confirm": "Confirmar", + "confirmAction": "Confirmar acción", + "@confirmAction": { + "description": "Typically used as a title to confirm a previously selected action" + }, + "areYouSureYouWantToContinue": "¿Seguro que quieres continuar?", + "bulkEditTagsAddMessage": "{count, plural, one{Esta acción agregará las etiquetas {tags} al documento seleccionado.} other{Esta acción agregará las etiquetas {tags} a los {count} documentos seleccionados.}}", + "@bulkEditTagsAddMessage": { + "description": "Message of the confirmation dialog when bulk adding tags." + }, + "bulkEditTagsRemoveMessage": "{count, plural, one{Esta acción eliminará las etiquetas {tags} del documento seleccionado.} other{Esta acción eliminará las etiquetas {tags} de los {count} documentos seleccionados.}}", + "@bulkEditTagsRemoveMessage": { + "description": "Message of the confirmation dialog when bulk removing tags." + }, + "bulkEditTagsModifyMessage": "{count, plural, one{Esta acción agregará las etiquetas {addTags} y eliminará las etiquetas {removeTags} del documento seleccionado.} other{Esta acción agregará las etiquetas {addTags} y eliminará las etiquetas {removeTags} de los {count} documentos seleccionados.}}", + "@bulkEditTagsModifyMessage": { + "description": "Message of the confirmation dialog when both adding and removing tags." + }, + "bulkEditCorrespondentAssignMessage": "{count, plural, one{Esta acción asignará el interlocutor {correspondent} al documento seleccionado.} other{Esta operación asignará al interlocutor {correspondent} a los {count} documentos seleccionados.}}", + "bulkEditDocumentTypeAssignMessage": "{count, plural, one{Esta acción asignará el tipo de documento {docType} al documento seleccionado.} other{Esta acción asignará el tipo de documento {docType} a los {count} documentos seleccionados.}}", + "bulkEditStoragePathAssignMessage": "{count, plural, one{Esta acción asignará la ruta de almacenamiento {path} al documento seleccionado.} other{Esta acción asignará la ruta de almacenamiento {path} a los {count} documentos seleccionados.}}", + "bulkEditCorrespondentRemoveMessage": "{count, plural, one{Esta acción eliminará al interlocutor del documento seleccionado.} other{Esta acción eliminará al interlocutor de los {count} documentos seleccionados.}}", + "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{Esta acción eliminará el tipo de documento del documento seleccionado.} other{Esta acción eliminará el tipo de documento de los {count} documentos seleccionados.}}", + "bulkEditStoragePathRemoveMessage": "{count, plural, one{Esta acción eliminará la ruta de almacenamiento del documento seleccionado.} other{Esta acción eliminará la ruta de almacenamiento de los {count} documentos seleccionados.}}", + "anyTag": "Cualquiera", + "@anyTag": { + "description": "Label shown when any tag should be filtered" + }, + "allTags": "Todo", + "@allTags": { + "description": "Label shown when a document has to be assigned to all selected tags" + }, + "switchingAccountsPleaseWait": "Cambiando cuentas. Por favor, espere...", + "@switchingAccountsPleaseWait": { + "description": "Message shown while switching accounts is in progress." + }, + "testConnection": "Prueba de conexión", + "@testConnection": { + "description": "Button label shown on login page. Allows user to test whether the server is reachable or not." + }, + "accounts": "Cuentas", + "@accounts": { + "description": "Title of the account management dialog" + }, + "addAccount": "Añadir cuenta", + "@addAccount": { + "description": "Label of add account action" + }, + "switchAccount": "Cambiar", + "@switchAccount": { + "description": "Label for switch account action" + }, + "logout": "Cerrar sesión", + "@logout": { + "description": "Generic Logout label" + }, + "switchAccountTitle": "Cambiar de cuenta", + "@switchAccountTitle": { + "description": "Title of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." + }, + "switchToNewAccount": "¿Quiere cambiar a una nueva cuenta? Puedes volver a la anterior en cualquier momento.", + "@switchToNewAccount": { + "description": "Content of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." + }, + "sourceCode": "Código Fuente", + "findTheSourceCodeOn": "Encuentra el código fuente en", + "@findTheSourceCodeOn": { + "description": "Text before link to Paperless Mobile GitHub" + }, + "rememberDecision": "Recuerda mi decisión", + "defaultDownloadFileType": "Tipo de archivo predeterminado para descargar", + "@defaultDownloadFileType": { + "description": "Label indicating the default filetype to download (one of archived, original and always ask)" + }, + "defaultShareFileType": "Tipo de archivo predeterminado para compartir", + "@defaultShareFileType": { + "description": "Label indicating the default filetype to share (one of archived, original and always ask)" + }, + "alwaysAsk": "Preguntar siempre", + "@alwaysAsk": { + "description": "Option to choose when the app should always ask the user which filetype to use" + }, + "disableMatching": "No etiquetar archivos automáticamente", + "@disableMatching": { + "description": "One of the options for automatic tagging of documents" + }, + "none": "Ninguno", + "@none": { + "description": "One of available enum values of matching algorithm for tags" + }, + "logInToExistingAccount": "Iniciar sesión en una cuenta existente", + "@logInToExistingAccount": { + "description": "Title shown on login page if at least one user is already known to the app." + }, + "print": "Imprimir", + "@print": { + "description": "Tooltip for print button" + }, + "managePermissions": "Administrar permisos", + "@managePermissions": { + "description": "Button which leads user to manage permissions page" + }, + "errorRetrievingServerVersion": "Ocurrió un error intentando determinar la versión del servidor.", + "@errorRetrievingServerVersion": { + "description": "Message shown at the bottom of the settings page when the remote server version could not be resolved." + }, + "resolvingServerVersion": "Determinando la versión del servidor...", + "@resolvingServerVersion": { + "description": "Message shown while the app is loading the remote server version." + }, + "goToLogin": "Ir al inicio de sesión", + "@goToLogin": { + "description": "Label of the button shown on the login page to skip logging in to existing accounts and navigate user to login page" + }, + "export": "Exportar", + "@export": { + "description": "Label for button that exports scanned images to pdf (before upload)" + }, + "invalidFilenameCharacter": "Carácter(es) inválido(s) en el nombre del archivo: {characters}", + "@invalidFilenameCharacter": { + "description": "For validating filename in export dialogue" + }, + "exportScansToPdf": "Exportar escaneos a PDF", + "@exportScansToPdf": { + "description": "title of the alert dialog when exporting scans to pdf" + }, + "allScansWillBeMerged": "Todos los escaneos serán combinados en un único archivo PDF.", + "behavior": "Comportamiento", + "@behavior": { + "description": "Title of the settings concerning app beahvior" + }, + "theme": "Tema", + "@theme": { + "description": "Title of the theme mode setting" + }, + "clearCache": "Borrar caché", + "@clearCache": { + "description": "Title of the clear cache setting" + }, + "freeBytes": "{byteString} libres", + "@freeBytes": { + "description": "Text shown for clear storage settings" + }, + "calculatingDots": "Calculando...", + "@calculatingDots": { + "description": "Text shown when the byte size is still being calculated" + }, + "freedDiskSpace": "{bytes} borrados del disco correctamente.", + "@freedDiskSpace": { + "description": "Message shown after clearing storage" + }, + "uploadScansAsPdf": "Subir escaneos como PDF", + "@uploadScansAsPdf": { + "description": "Title of the setting which toggles whether scans are always uploaded as pdf" + }, + "convertSinglePageScanToPdf": "Convertir siempre escaneos de una sola página a PDF antes de subirlos", + "@convertSinglePageScanToPdf": { + "description": "description of the upload scans as pdf setting" + }, + "loginRequiredPermissionsHint": "El uso de Paperless Mobile requiere un conjunto mínimo de permisos de usuario de paperless-ngx desde la versión 1.14.0 en adelante. Por lo tanto, asegúrese de que el usuario que inicie sesión tenga permiso para ver otros usuarios (Usuario → Vista) y sus configuraciones (Ajustes de UI → Vista). Si no tiene estos permisos, contacte al administrador de su servidor de paperless-ngx.", + "@loginRequiredPermissionsHint": { + "description": "Hint shown on the login page informing the user of the required permissions to use the app." + }, + "missingPermissions": "No tiene los permisos necesarios para realizar esta acción.", + "@missingPermissions": { + "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donar", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" + } +} \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 71dc73d0..7e488cee 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donations", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index a6b5d0f7..99b1d8a0 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Czy na pewno chcesz usunąć ten widok?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Usuń widok ", + "deleteView": "Usuń widok {name}?", "@deleteView": {}, "addedAt": "Dodano", "@addedAt": {}, @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index a893206f..aa3d58b7 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Do you really want to delete this view?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Delete view ", + "deleteView": "", "@deleteView": {}, "addedAt": "Added at", "@addedAt": {}, @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donate", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index ea528091..acebfb54 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Bu görünümü gerçekten silmek istiyor musunuz?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Görünümü sil", + "deleteView": "Görünümü sil {name}?", "@deleteView": {}, "addedAt": "Added at", "@addedAt": {}, @@ -864,5 +864,17 @@ "missingPermissions": "You do not have the necessary permissions to perform this action.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." + }, + "editView": "Edit View", + "@editView": { + "description": "Title of the edit saved view page" + }, + "donate": "Donate", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index dfc2669b..9d7b7404 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,6 +34,7 @@ import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; @@ -42,6 +43,7 @@ import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; @@ -231,27 +233,7 @@ class _GoRouterShellState extends State { navigatorKey: rootNavigatorKey, builder: ProviderShellRoute(widget.apiFactory).build, routes: [ - // GoRoute( - // parentNavigatorKey: rootNavigatorKey, - // name: R.savedView, - // path: "/saved_view/:id", - // builder: (context, state) { - // return Placeholder( - // child: Text("Documents"), - // ); - // }, - // routes: [ - // GoRoute( - // path: "create", - // name: R.createSavedView, - // builder: (context, state) { - // return Placeholder( - // child: Text("Documents"), - // ); - // }, - // ), - // ], - // ), + $savedViewsRoute, StatefulShellRoute( navigatorContainerBuilder: (context, navigationShell, children) { return children[navigationShell.currentIndex]; diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 308003a0..0dc9dd4d 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -7,6 +7,7 @@ class R { static const switchingAccounts = "switchingAccounts"; static const savedView = "savedView"; static const createSavedView = "createSavedView"; + static const editSavedView = "editSavedView"; static const documentDetails = "documentDetails"; static const editDocument = "editDocument"; static const labels = "labels"; diff --git a/lib/routes/typed/branches/saved_views_route.dart b/lib/routes/typed/branches/saved_views_route.dart index 2967e542..00911703 100644 --- a/lib/routes/typed/branches/saved_views_route.dart +++ b/lib/routes/typed/branches/saved_views_route.dart @@ -2,8 +2,24 @@ import 'package:flutter/src/widgets/framework.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; +import 'package:paperless_mobile/features/saved_view/view/edit_saved_view_page.dart'; +import 'package:paperless_mobile/routes/routes.dart'; -@TypedGoRoute(path: "/saved-views", routes: []) +part 'saved_views_route.g.dart'; + +@TypedGoRoute( + path: "/saved-views", + routes: [ + TypedGoRoute( + path: "create", + name: R.createSavedView, + ), + TypedGoRoute( + path: "edit", + name: R.editSavedView, + ), + ], +) class SavedViewsRoute extends GoRouteData { const SavedViewsRoute(); } @@ -14,12 +30,16 @@ class CreateSavedViewRoute extends GoRouteData { @override Widget build(BuildContext context, GoRouterState state) { - return AddSavedViewPage( - initialFilter: $extra, - ); + return AddSavedViewPage(initialFilter: $extra); } } class EditSavedViewRoute extends GoRouteData { - const EditSavedViewRoute(); + final SavedView $extra; + const EditSavedViewRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return EditSavedViewPage(savedView: $extra); + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/text_query.dart b/packages/paperless_api/lib/src/models/query_parameters/text_query.dart index 27dbe027..93e371f5 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/text_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/text_query.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/config/hive/hive_type_ids.dart'; @@ -10,7 +11,7 @@ part 'text_query.g.dart'; //TODO: Realize with freezed... @HiveType(typeId: PaperlessApiHiveTypeIds.textQuery) @JsonSerializable() -class TextQuery extends Equatable { +class TextQuery { @HiveField(0) final QueryType queryType; @HiveField(1) @@ -23,7 +24,8 @@ class TextQuery extends Equatable { const TextQuery.title(this.queryText) : queryType = QueryType.title; - const TextQuery.titleAndContent(this.queryText) : queryType = QueryType.titleAndContent; + const TextQuery.titleAndContent(this.queryText) + : queryType = QueryType.titleAndContent; const TextQuery.extended(this.queryText) : queryType = QueryType.extended; @@ -73,7 +75,8 @@ class TextQuery extends Equatable { case QueryType.title: return title.contains(queryText!); case QueryType.titleAndContent: - return title.contains(queryText!) || (content?.contains(queryText!) ?? false); + return title.contains(queryText!) || + (content?.contains(queryText!) ?? false); case QueryType.extended: //TODO: Implement. Might be too complex... return true; @@ -84,8 +87,19 @@ class TextQuery extends Equatable { Map toJson() => _$TextQueryToJson(this); - factory TextQuery.fromJson(Map json) => _$TextQueryFromJson(json); + factory TextQuery.fromJson(Map json) => + _$TextQueryFromJson(json); @override - List get props => [queryType, queryText]; + bool operator ==(Object? other) { + if (identical(this, other)) return true; + if (other is! TextQuery) return false; + if (queryText == null && other.queryText == null) { + return true; + } + return other.queryText == queryText && other.queryType == queryType; + } + + @override + int get hashCode => Object.hash(queryText, queryType); } From 18ab65793241335808ce7beae450fd18f7539bf5 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 22 Sep 2023 00:46:24 +0200 Subject: [PATCH 08/12] feat: Update translations, finish saved views rework, some other fixes --- lib/core/config/hive/hive_extensions.dart | 10 +- .../database/tables/local_user_account.dart | 3 +- lib/core/navigation/push_routes.dart | 1 - .../repository/persistent_repository.dart | 15 +- .../error_code_localization_mapper.dart | 31 +- .../pop_with_unsaved_changes.dart | 31 ++ .../unsaved_changes_warning_dialog.dart | 24 + lib/core/widgets/empty_state.dart | 45 -- .../view/pages/document_details_page.dart | 2 - .../view/document_edit_page.dart | 471 ++++++++--------- .../document_scan/view/scanner_page.dart | 2 +- .../cubit/document_search_cubit.dart | 25 +- .../view/document_search_bar.dart | 125 +++-- .../view/document_search_page.dart | 10 +- .../view/sliver_search_bar.dart | 11 +- .../documents/view/pages/documents_page.dart | 486 +++++++----------- .../view/widgets/documents_empty_state.dart | 33 +- .../saved_view_changed_dialog.dart | 12 +- .../widgets/saved_views/saved_view_chip.dart | 3 +- .../saved_views/saved_views_widget.dart | 262 ++++++---- .../document_selection_sliver_app_bar.dart | 1 - .../edit_label/view/edit_label_page.dart | 48 +- lib/features/edit_label/view/label_form.dart | 8 +- lib/features/home/view/home_shell_widget.dart | 6 - .../view/scaffold_with_navigation_bar.dart | 165 +++--- lib/features/inbox/view/pages/inbox_page.dart | 68 ++- lib/features/labels/cubit/label_cubit.dart | 11 +- .../labels/view/pages/labels_page.dart | 21 +- lib/features/landing/view/landing_page.dart | 92 ++-- .../landing/view/widgets/expansion_card.dart | 12 +- .../login/cubit/authentication_cubit.dart | 10 +- .../services/local_notification_service.dart | 2 +- .../saved_view/cubit/saved_view_cubit.dart | 2 +- .../saved_view/view/add_saved_view_page.dart | 1 - .../view/saved_view_preview.dart | 94 ++-- .../widgets/color_scheme_option_setting.dart | 6 +- .../view/widgets/theme_mode_setting.dart | 6 +- lib/l10n/intl_ca.arb | 95 +++- lib/l10n/intl_cs.arb | 95 +++- lib/l10n/intl_de.arb | 95 +++- lib/l10n/intl_en.arb | 95 +++- lib/l10n/intl_es.arb | 93 ++++ lib/l10n/intl_fr.arb | 95 +++- lib/l10n/intl_pl.arb | 93 ++++ lib/l10n/intl_ru.arb | 93 ++++ lib/l10n/intl_tr.arb | 93 ++++ lib/main.dart | 6 +- lib/routes/navigation_keys.dart | 1 + .../typed/branches/documents_route.dart | 25 +- lib/routes/typed/branches/landing_route.dart | 13 - .../typed/top_level/settings_route.dart | 13 +- lib/theme.dart | 33 ++ .../paperless_saved_views_api_impl.dart | 2 +- pubspec.lock | 8 + pubspec.yaml | 3 +- 55 files changed, 2034 insertions(+), 1072 deletions(-) create mode 100644 lib/core/widgets/dialog_utils/pop_with_unsaved_changes.dart create mode 100644 lib/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart delete mode 100644 lib/core/widgets/empty_state.dart diff --git a/lib/core/config/hive/hive_extensions.dart b/lib/core/config/hive/hive_extensions.dart index 7a6c0e26..ee440856 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/config/hive/hive_extensions.dart @@ -10,7 +10,9 @@ import 'package:hive_flutter/adapters.dart'; /// [callback] to return and returns the calculated value. Closes the box after. /// Future withEncryptedBox( - String name, FutureOr Function(Box box) callback) async { + String name, + FutureOr Function(Box box) callback, +) async { final key = await _getEncryptedBoxKey(); final box = await Hive.openBox( name, @@ -22,7 +24,11 @@ Future withEncryptedBox( } Future _getEncryptedBoxKey() async { - const secureStorage = FlutterSecureStorage(); + const secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + ); if (!await secureStorage.containsKey(key: 'key')) { final key = Hive.generateSecureKey(); diff --git a/lib/core/database/tables/local_user_account.dart b/lib/core/database/tables/local_user_account.dart index 799ddb18..d54d890d 100644 --- a/lib/core/database/tables/local_user_account.dart +++ b/lib/core/database/tables/local_user_account.dart @@ -1,8 +1,7 @@ import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; -import 'package:paperless_api/paperless_api.dart'; part 'local_user_account.g.dart'; diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart index 39651f66..3bd90c0e 100644 --- a/lib/core/navigation/push_routes.dart +++ b/lib/core/navigation/push_routes.dart @@ -13,7 +13,6 @@ import 'package:paperless_mobile/features/document_bulk_action/view/widgets/full import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/core/repository/persistent_repository.dart b/lib/core/repository/persistent_repository.dart index db63c135..8df0084f 100644 --- a/lib/core/repository/persistent_repository.dart +++ b/lib/core/repository/persistent_repository.dart @@ -8,25 +8,26 @@ abstract class PersistentRepository extends HydratedCubit { PersistentRepository(T initialState) : super(initialState); void addListener( - Object source, { + Object subscriber, { required void Function(T) onChanged, }) { onChanged(state); - _subscribers.putIfAbsent(source, () { + _subscribers.putIfAbsent(subscriber, () { return stream.listen((event) => onChanged(event)); }); } void removeListener(Object source) async { - await _subscribers[source]?.cancel(); - _subscribers.remove(source); + _subscribers + ..[source]?.cancel() + ..remove(source); } @override Future close() { - _subscribers.forEach((key, subscription) { - subscription.cancel(); - }); + for (final subscriber in _subscribers.values) { + subscriber.cancel(); + } return super.close(); } } diff --git a/lib/core/translation/error_code_localization_mapper.dart b/lib/core/translation/error_code_localization_mapper.dart index 4e653e7c..c720d9ea 100644 --- a/lib/core/translation/error_code_localization_mapper.dart +++ b/lib/core/translation/error_code_localization_mapper.dart @@ -54,25 +54,26 @@ String translateError(BuildContext context, ErrorCode code) { ErrorCode.suggestionsQueryError => S.of(context)!.couldNotLoadSuggestions, ErrorCode.acknowledgeTasksError => S.of(context)!.couldNotAcknowledgeTasks, ErrorCode.correspondentDeleteFailed => - "Could not delete correspondent, please try again.", //TODO: INTL + S.of(context)!.couldNotDeleteCorrespondent, ErrorCode.documentTypeDeleteFailed => - "Could not delete document type, please try again.", - ErrorCode.tagDeleteFailed => "Could not delete tag, please try again.", + S.of(context)!.couldNotDeleteDocumentType, + ErrorCode.tagDeleteFailed => S.of(context)!.couldNotDeleteTag, + ErrorCode.storagePathDeleteFailed => + S.of(context)!.couldNotDeleteStoragePath, ErrorCode.correspondentUpdateFailed => - "Could not update correspondent, please try again.", + S.of(context)!.couldNotUpdateCorrespondent, ErrorCode.documentTypeUpdateFailed => - "Could not update document type, please try again.", - ErrorCode.tagUpdateFailed => "Could not update tag, please try again.", - ErrorCode.storagePathDeleteFailed => - "Could not delete storage path, please try again.", + S.of(context)!.couldNotUpdateDocumentType, + ErrorCode.tagUpdateFailed => S.of(context)!.couldNotUpdateTag, ErrorCode.storagePathUpdateFailed => - "Could not update storage path, please try again.", + S.of(context)!.couldNotUpdateStoragePath, ErrorCode.serverInformationLoadFailed => - "Could not load server information.", - ErrorCode.serverStatisticsLoadFailed => "Could not load server statistics.", - ErrorCode.uiSettingsLoadFailed => "Could not load UI settings", - ErrorCode.loadTasksError => "Could not load tasks.", - ErrorCode.userNotFound => "User could not be found.", - ErrorCode.updateSavedViewError => "Could not update saved view.", + S.of(context)!.couldNotLoadServerInformation, + ErrorCode.serverStatisticsLoadFailed => + S.of(context)!.couldNotLoadStatistics, + ErrorCode.uiSettingsLoadFailed => S.of(context)!.couldNotLoadUISettings, + ErrorCode.loadTasksError => S.of(context)!.couldNotLoadTasks, + ErrorCode.userNotFound => S.of(context)!.userNotFound, + ErrorCode.updateSavedViewError => S.of(context)!.couldNotUpdateSavedView, }; } diff --git a/lib/core/widgets/dialog_utils/pop_with_unsaved_changes.dart b/lib/core/widgets/dialog_utils/pop_with_unsaved_changes.dart new file mode 100644 index 00000000..b171007a --- /dev/null +++ b/lib/core/widgets/dialog_utils/pop_with_unsaved_changes.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart'; + +class PopWithUnsavedChanges extends StatelessWidget { + final bool Function() hasChangesPredicate; + final Widget child; + + const PopWithUnsavedChanges({ + super.key, + required this.hasChangesPredicate, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + if (hasChangesPredicate()) { + final shouldPop = await showDialog( + context: context, + builder: (context) => const UnsavedChangesWarningDialog(), + ) ?? + false; + return shouldPop; + } + return true; + }, + child: child, + ); + } +} diff --git a/lib/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart b/lib/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart new file mode 100644 index 00000000..8846031d --- /dev/null +++ b/lib/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.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/generated/l10n/app_localizations.dart'; + +class UnsavedChangesWarningDialog extends StatelessWidget { + const UnsavedChangesWarningDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Discard changes?"), + content: Text( + "You have unsaved changes. Do you want to continue without saving? Your changes will be discarded.", + ), + actions: [ + DialogCancelButton(), + DialogConfirmButton( + label: S.of(context)!.continueLabel, + ), + ], + ); + } +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart deleted file mode 100644 index 2b179ed5..00000000 --- a/lib/core/widgets/empty_state.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -class EmptyState extends StatelessWidget { - final String title; - final String subtitle; - final Widget? bottomChild; - - const EmptyState({ - Key? key, - required this.title, - required this.subtitle, - this.bottomChild, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: size.height / 3, - width: size.width / 3, - child: SvgPicture.asset("assets/images/empty-state.svg"), - ), - Column( - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - subtitle, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - if (bottomChild != null) ...[bottomChild!] else ...[] - ], - ); - } -} 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 97b8c7be..aece9c15 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -15,10 +15,8 @@ import 'package:paperless_mobile/features/document_details/view/widgets/document import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; -import 'package:paperless_mobile/features/documents/view/pages/document_view.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/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 8453c24c..defa4199 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -5,11 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:go_router/go_router.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/repository/label_repository.dart'; +import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; @@ -19,7 +19,6 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; class DocumentEditPage extends StatefulWidget { @@ -46,253 +45,257 @@ class _DocumentEditPageState extends State { @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; - return BlocBuilder( - builder: (context, state) { - final filteredSuggestions = state.suggestions?.documentDifference( - context.read().state.document); - return DefaultTabController( - length: 2, - child: Scaffold( - resizeToAvoidBottomInset: false, - floatingActionButton: FloatingActionButton.extended( - heroTag: "fab_document_edit", - onPressed: () => _onSubmit(state.document), - icon: const Icon(Icons.save), - label: Text(S.of(context)!.saveChanges), - ), - appBar: AppBar( - title: Text(S.of(context)!.editDocument), - bottom: TabBar( - tabs: [ - Tab( - text: S.of(context)!.overview, - ), - Tab( - text: S.of(context)!.content, - ) - ], + return PopWithUnsavedChanges( + hasChangesPredicate: () => _formKey.currentState?.isDirty ?? false, + child: BlocBuilder( + builder: (context, state) { + final filteredSuggestions = state.suggestions?.documentDifference( + context.read().state.document); + return DefaultTabController( + length: 2, + child: Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_document_edit", + onPressed: () => _onSubmit(state.document), + icon: const Icon(Icons.save), + label: Text(S.of(context)!.saveChanges), ), - ), - extendBody: true, - body: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - top: 8, - left: 8, - right: 8, + appBar: AppBar( + title: Text(S.of(context)!.editDocument), + bottom: TabBar( + tabs: [ + Tab(text: S.of(context)!.overview), + Tab(text: S.of(context)!.content) + ], + ), ), - child: FormBuilder( - key: _formKey, - child: TabBarView( - children: [ - ListView( - children: [ - _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField( - state.document.created, - filteredSuggestions, - ).padded(), - // Correspondent form field - if (currentUser.canViewCorrespondents) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddCorrespondentPage( - initialName: initialValue, + extendBody: true, + body: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + top: 8, + left: 8, + right: 8, + ), + child: FormBuilder( + key: _formKey, + child: TabBarView( + children: [ + ListView( + children: [ + _buildTitleFormField(state.document.title).padded(), + _buildCreatedAtFormField( + state.document.created, + filteredSuggestions, + ).padded(), + // Correspondent form field + if (currentUser.canViewCorrespondents) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddCorrespondentPage( + initialName: initialValue, + ), ), + addLabelText: + S.of(context)!.addCorrespondent, + labelText: S.of(context)!.correspondent, + options: context + .watch() + .state + .correspondents, + initialValue: + state.document.correspondent != null + ? IdQueryParameter.fromId( + state.document.correspondent!) + : const IdQueryParameter.unset(), + name: fkCorrespondent, + prefixIcon: + const Icon(Icons.person_outlined), + allowSelectUnassigned: true, + canCreateNewLabel: + currentUser.canCreateCorrespondents, ), - addLabelText: S.of(context)!.addCorrespondent, - labelText: S.of(context)!.correspondent, - options: context - .watch() - .state - .correspondents, - initialValue: - state.document.correspondent != null - ? IdQueryParameter.fromId( - state.document.correspondent!) - : const IdQueryParameter.unset(), - name: fkCorrespondent, - prefixIcon: const Icon(Icons.person_outlined), - allowSelectUnassigned: true, - canCreateNewLabel: - currentUser.canCreateCorrespondents, - ), - if (filteredSuggestions - ?.hasSuggestedCorrespondents ?? - false) - _buildSuggestionsSkeleton( - suggestions: - filteredSuggestions!.correspondents, - itemBuilder: (context, itemData) => - ActionChip( - label: Text( - state.correspondents[itemData]!.name), - onPressed: () { - _formKey.currentState - ?.fields[fkCorrespondent] - ?.didChange( - IdQueryParameter.fromId(itemData), - ); - }, + if (filteredSuggestions + ?.hasSuggestedCorrespondents ?? + false) + _buildSuggestionsSkeleton( + suggestions: + filteredSuggestions!.correspondents, + itemBuilder: (context, itemData) => + ActionChip( + label: Text(state + .correspondents[itemData]!.name), + onPressed: () { + _formKey.currentState + ?.fields[fkCorrespondent] + ?.didChange( + IdQueryParameter.fromId(itemData), + ); + }, + ), ), - ), - ], - ).padded(), - // DocumentType form field - if (currentUser.canViewDocumentTypes) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (currentInput) => - RepositoryProvider.value( - value: context.read(), - child: AddDocumentTypePage( - initialName: currentInput, + ], + ).padded(), + // DocumentType form field + if (currentUser.canViewDocumentTypes) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (currentInput) => + RepositoryProvider.value( + value: context.read(), + child: AddDocumentTypePage( + initialName: currentInput, + ), ), + canCreateNewLabel: + currentUser.canCreateDocumentTypes, + addLabelText: + S.of(context)!.addDocumentType, + labelText: S.of(context)!.documentType, + initialValue: + state.document.documentType != null + ? IdQueryParameter.fromId( + state.document.documentType!) + : const IdQueryParameter.unset(), + options: state.documentTypes, + name: _DocumentEditPageState.fkDocumentType, + prefixIcon: + const Icon(Icons.description_outlined), + allowSelectUnassigned: true, ), - canCreateNewLabel: - currentUser.canCreateDocumentTypes, - addLabelText: S.of(context)!.addDocumentType, - labelText: S.of(context)!.documentType, - initialValue: - state.document.documentType != null - ? IdQueryParameter.fromId( - state.document.documentType!) - : const IdQueryParameter.unset(), - options: state.documentTypes, - name: _DocumentEditPageState.fkDocumentType, - prefixIcon: - const Icon(Icons.description_outlined), - allowSelectUnassigned: true, - ), - if (filteredSuggestions - ?.hasSuggestedDocumentTypes ?? - false) - _buildSuggestionsSkeleton( - suggestions: - filteredSuggestions!.documentTypes, - itemBuilder: (context, itemData) => - ActionChip( - label: Text( - state.documentTypes[itemData]!.name), - onPressed: () => _formKey - .currentState?.fields[fkDocumentType] - ?.didChange( - IdQueryParameter.fromId(itemData), + if (filteredSuggestions + ?.hasSuggestedDocumentTypes ?? + false) + _buildSuggestionsSkeleton( + suggestions: + filteredSuggestions!.documentTypes, + itemBuilder: (context, itemData) => + ActionChip( + label: Text(state + .documentTypes[itemData]!.name), + onPressed: () => _formKey.currentState + ?.fields[fkDocumentType] + ?.didChange( + IdQueryParameter.fromId(itemData), + ), ), ), + ], + ).padded(), + // StoragePath form field + if (currentUser.canViewStoragePaths) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddStoragePathPage( + initialName: initialValue), + ), + canCreateNewLabel: + currentUser.canCreateStoragePaths, + addLabelText: S.of(context)!.addStoragePath, + labelText: S.of(context)!.storagePath, + options: state.storagePaths, + initialValue: + state.document.storagePath != null + ? IdQueryParameter.fromId( + state.document.storagePath!) + : const IdQueryParameter.unset(), + name: fkStoragePath, + prefixIcon: + const Icon(Icons.folder_outlined), + allowSelectUnassigned: true, ), - ], - ).padded(), - // StoragePath form field - if (currentUser.canViewStoragePaths) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddStoragePathPage( - initialName: initialValue), - ), - canCreateNewLabel: - currentUser.canCreateStoragePaths, - addLabelText: S.of(context)!.addStoragePath, - labelText: S.of(context)!.storagePath, - options: state.storagePaths, - initialValue: - state.document.storagePath != null - ? IdQueryParameter.fromId( - state.document.storagePath!) - : const IdQueryParameter.unset(), - name: fkStoragePath, - prefixIcon: const Icon(Icons.folder_outlined), - allowSelectUnassigned: true, + ], + ).padded(), + // Tag form field + if (currentUser.canViewTags) + TagsFormField( + options: state.tags, + name: fkTags, + allowOnlySelection: true, + allowCreation: true, + allowExclude: false, + initialValue: TagsQuery.ids( + include: state.document.tags.toList(), ), - ], - ).padded(), - // Tag form field - if (currentUser.canViewTags) - TagsFormField( - options: state.tags, - name: fkTags, - allowOnlySelection: true, - allowCreation: true, - allowExclude: false, - initialValue: TagsQuery.ids( - include: state.document.tags.toList(), - ), - ).padded(), - if (filteredSuggestions?.tags - .toSet() - .difference(state.document.tags.toSet()) - .isNotEmpty ?? - false) - _buildSuggestionsSkeleton( - suggestions: - (filteredSuggestions?.tags.toSet() ?? {}), - itemBuilder: (context, itemData) { - final tag = state.tags[itemData]!; - return ActionChip( - label: Text( - tag.name, - style: TextStyle(color: tag.textColor), - ), - backgroundColor: tag.color, - onPressed: () { - final currentTags = _formKey.currentState - ?.fields[fkTags]?.value as TagsQuery; - _formKey.currentState?.fields[fkTags] - ?.didChange( - currentTags.maybeWhen( - ids: (include, exclude) => - TagsQuery.ids( - include: [...include, itemData], - exclude: exclude), - orElse: () => - TagsQuery.ids(include: [itemData]), - ), - ); - }, - ); - }, - ), - // Prevent tags from being hidden by fab - const SizedBox(height: 64), - ], - ), - SingleChildScrollView( - child: Column( - children: [ - FormBuilderTextField( - name: fkContent, - maxLines: null, - keyboardType: TextInputType.multiline, - initialValue: state.document.content, - decoration: const InputDecoration( - border: InputBorder.none, + ).padded(), + if (filteredSuggestions?.tags + .toSet() + .difference(state.document.tags.toSet()) + .isNotEmpty ?? + false) + _buildSuggestionsSkeleton( + suggestions: + (filteredSuggestions?.tags.toSet() ?? {}), + itemBuilder: (context, itemData) { + final tag = state.tags[itemData]!; + return ActionChip( + label: Text( + tag.name, + style: TextStyle(color: tag.textColor), + ), + backgroundColor: tag.color, + onPressed: () { + final currentTags = _formKey.currentState + ?.fields[fkTags]?.value as TagsQuery; + _formKey.currentState?.fields[fkTags] + ?.didChange( + currentTags.maybeWhen( + ids: (include, exclude) => + TagsQuery.ids(include: [ + ...include, + itemData + ], exclude: exclude), + orElse: () => TagsQuery.ids( + include: [itemData]), + ), + ); + }, + ); + }, ), - ), - const SizedBox(height: 84), + // Prevent tags from being hidden by fab + const SizedBox(height: 64), ], ), - ), - ], + SingleChildScrollView( + child: Column( + children: [ + FormBuilderTextField( + name: fkContent, + maxLines: null, + keyboardType: TextInputType.multiline, + initialValue: state.document.content, + decoration: const InputDecoration( + border: InputBorder.none, + ), + ), + const SizedBox(height: 84), + ], + ), + ), + ], + ), ), - ), - )), - ); - }, + )), + ); + }, + ), ); } diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index fca17f97..760e7b5c 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -14,7 +14,6 @@ import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; @@ -58,6 +57,7 @@ class _ScannerPageState extends State return BlocBuilder>( builder: (context, state) { return SafeArea( + top: true, child: Scaffold( drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart index 61f045b7..b8ee1255 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -24,8 +24,10 @@ class DocumentSearchCubit extends Cubit this.api, this.notifier, this._userAppState, - ) : super(DocumentSearchState( - searchHistory: _userAppState.documentSearchHistory)) { + ) : super( + DocumentSearchState( + searchHistory: _userAppState.documentSearchHistory), + ) { notifier.addListener( this, onDeleted: remove, @@ -34,22 +36,25 @@ class DocumentSearchCubit extends Cubit } Future search(String query) async { - emit(state.copyWith( - isLoading: true, - suggestions: [], - view: SearchView.results, - )); + final normalizedQuery = query.trim(); + emit( + state.copyWith( + isLoading: true, + suggestions: [], + view: SearchView.results, + ), + ); final searchFilter = DocumentFilter( - query: TextQuery.extended(query), + query: TextQuery.extended(normalizedQuery), ); await updateFilter(filter: searchFilter); emit( state.copyWith( searchHistory: [ - query, + normalizedQuery, ...state.searchHistory - .whereNot((previousQuery) => previousQuery == query) + .whereNot((previousQuery) => previousQuery == normalizedQuery) ], ), ); diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index 6ed9eca6..3bdd755d 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -21,75 +21,70 @@ class DocumentSearchBar extends StatefulWidget { class _DocumentSearchBarState extends State { @override Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.only(top: 8), - child: OpenContainer( - transitionDuration: const Duration(milliseconds: 200), - transitionType: ContainerTransitionType.fadeThrough, - closedElevation: 1, - middleColor: Theme.of(context).colorScheme.surfaceVariant, - openColor: Theme.of(context).colorScheme.background, - closedColor: Theme.of(context).colorScheme.surfaceVariant, - closedShape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(56), - ), - closedBuilder: (_, action) { - return InkWell( - onTap: action, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 720, - minWidth: 360, - maxHeight: 56, - minHeight: 48, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.menu), - onPressed: Scaffold.of(context).openDrawer, - ), - Flexible( - child: Text( - S.of(context)!.searchDocuments, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).hintColor, - ), - ), + return OpenContainer( + transitionDuration: const Duration(milliseconds: 200), + transitionType: ContainerTransitionType.fadeThrough, + closedElevation: 1, + middleColor: Theme.of(context).colorScheme.surfaceVariant, + openColor: Theme.of(context).colorScheme.background, + closedColor: Theme.of(context).colorScheme.surfaceVariant, + closedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(56), + ), + closedBuilder: (_, action) { + return InkWell( + onTap: action, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 720, + minWidth: 360, + maxHeight: 56, + minHeight: 48, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.menu), + onPressed: Scaffold.of(context).openDrawer, + ), + Flexible( + child: Text( + S.of(context)!.searchDocuments, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), ), - ], - ), + ), + ], ), ), - _buildUserAvatar(context), - ], - ), + ), + _buildUserAvatar(context), + ], ), - ); - }, - openBuilder: (_, action) { - return Provider( - create: (_) => DocumentSearchCubit( - context.read(), - context.read(), - Hive.box(HiveBoxes.localUserAppState) - .get(context.read().id)!, - ), - child: const DocumentSearchPage(), - ); - }, - ), + ), + ); + }, + openBuilder: (_, action) { + return Provider( + create: (_) => DocumentSearchCubit( + context.read(), + context.read(), + Hive.box(HiveBoxes.localUserAppState) + .get(context.read().id)!, + ), + child: const DocumentSearchPage(), + ); + }, ); } diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index 8e43fa17..6ae14f20 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,8 +4,6 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart'; @@ -188,7 +186,7 @@ class _DocumentSearchPageState extends State { children: [ Text( S.of(context)!.results, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.labelMedium, ), BlocBuilder( builder: (context, state) { @@ -200,15 +198,15 @@ class _DocumentSearchPageState extends State { }, ) ], - ).padded(); + ).paddedLTRB(16, 8, 8, 8); return CustomScrollView( slivers: [ SliverToBoxAdapter(child: header), if (state.hasLoaded && !state.isLoading && state.documents.isEmpty) SliverToBoxAdapter( child: Center( - child: Text(S.of(context)!.noMatchesFound), - ), + child: Text(S.of(context)!.noDocumentsFound), + ).paddedOnly(top: 8), ) else SliverAdaptiveDocumentsView( diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 072b331e..f0ecff15 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; @@ -8,6 +9,7 @@ import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; class SliverSearchBar extends StatelessWidget { final bool floating; @@ -22,14 +24,13 @@ class SliverSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + if (context.watch().paperlessUser.canViewDocuments) { return SliverAppBar( - toolbarHeight: kToolbarHeight, - flexibleSpace: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0), - child: const DocumentSearchBar(), - ), + titleSpacing: 8, automaticallyImplyLeading: false, + title: DocumentSearchBar(), ); } else { return SliverAppBar( diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index c6ae53f9..9c1df845 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,11 +1,11 @@ -import 'package:badges/badges.dart' as b; import 'package:collection/collection.dart'; +import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.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/documents/cubit/documents_cubit.dart'; @@ -18,8 +18,8 @@ import 'package:paperless_mobile/features/documents/view/widgets/selection/confi import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; +import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -43,45 +43,58 @@ class DocumentsPage extends StatefulWidget { State createState() => _DocumentsPageState(); } -class _DocumentsPageState extends State - with SingleTickerProviderStateMixin { +class _DocumentsPageState extends State { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle(); final SliverOverlapAbsorberHandle savedViewsHandle = SliverOverlapAbsorberHandle(); - late final TabController _tabController; - int _currentTab = 0; + final _nestedScrollViewKey = GlobalKey(); + final _savedViewsExpansionController = ExpansionTileController(); + bool _showExtendedFab = true; + @override void initState() { super.initState(); - final showSavedViews = - context.read().paperlessUser.canViewSavedViews; - _tabController = TabController( - length: showSavedViews ? 2 : 1, - vsync: this, - ); - // Future.wait([ - // context.read().reload(), - // context.read().reload(), - // ]).onError( - // (error, stackTrace) { - // showErrorMessage(context, error, stackTrace); - // return []; - // }, - // ); - _tabController.addListener(_tabChangesListener); + WidgetsBinding.instance.addPostFrameCallback((_) { + _nestedScrollViewKey.currentState!.innerController + .addListener(_scrollExtentChangedListener); + }); } - void _tabChangesListener() { - setState(() => _currentTab = _tabController.index); + Future _reloadData() async { + try { + await Future.wait([ + context.read().reload(), + context.read().reload(), + context.read().reload(), + ]); + } catch (error, stackTrace) { + showGenericError(context, error, stackTrace); + } + } + + void _scrollExtentChangedListener() { + const threshold = 400; + final offset = + _nestedScrollViewKey.currentState!.innerController.position.pixels; + if (offset < threshold && _showExtendedFab == false) { + setState(() { + _showExtendedFab = true; + }); + } else if (offset >= threshold && _showExtendedFab == true) { + setState(() { + _showExtendedFab = false; + }); + } } @override void dispose() { - _tabController.dispose(); + _nestedScrollViewKey.currentState?.innerController + .removeListener(_scrollExtentChangedListener); super.dispose(); } @@ -109,11 +122,7 @@ class _DocumentsPageState extends State previous != ConnectivityState.connected && current == ConnectivityState.connected, listener: (context, state) { - try { - context.read().reload(); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } + _reloadData(); }, builder: (context, connectivityState) { return SafeArea( @@ -122,59 +131,104 @@ class _DocumentsPageState extends State drawer: const AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { - final appliedFiltersCount = state.filter.appliedFiltersCount; final show = state.selection.isEmpty; final canReset = state.filter.appliedFiltersCount > 0; - return AnimatedScale( - scale: show ? 1 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - child: Column( + if (show) { + return Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (canReset) - Padding( - padding: const EdgeInsets.all(8.0), - child: FloatingActionButton.small( - heroTag: "fab_documents_page_reset_filter", - backgroundColor: Theme.of(context) - .colorScheme - .onPrimaryContainer, - onPressed: () { - _onResetFilter(); - }, - child: Icon( - Icons.refresh, - color: Theme.of(context) - .colorScheme - .primaryContainer, + DeferredPointerHandler( + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton.extended( + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric( + horizontal: 16), + heroTag: "fab_documents_page_filter", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon( + Icons.filter_alt_outlined, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.filterDocuments, + ), + ], + ) + : const Icon(Icons.filter_alt_outlined), + ), + onPressed: _openDocumentFilter, ), - ), + if (canReset) + Positioned( + top: -20, + right: -8, + child: DeferPointer( + paintOnTop: true, + child: Material( + color: + Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + HapticFeedback.mediumImpact(); + _onResetFilter(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + if (_showExtendedFab) + Text( + "Reset (${state.filter.appliedFiltersCount})", + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onError, + ), + ).padded() + else + Icon( + Icons.replay, + color: Theme.of(context) + .colorScheme + .onError, + ).padded(4), + ], + ), + ), + ), + ), + ), + ], ), - b.Badge( - position: b.BadgePosition.topEnd(top: -12, end: -6), - showBadge: appliedFiltersCount > 0, - badgeContent: Text( - '$appliedFiltersCount', - style: const TextStyle( - color: Colors.white, - ), - ), - animationType: b.BadgeAnimationType.fade, - badgeColor: Colors.red, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: Builder(builder: (context) { - return FloatingActionButton( - heroTag: "fab_documents_page_filter", - child: const Icon(Icons.filter_alt_outlined), - onPressed: _openDocumentFilter, - ); - })), ), ], - ), - ); + ); + } else { + return const SizedBox.shrink(); + } }, ), resizeToAvoidBottomInset: true, @@ -190,94 +244,41 @@ class _DocumentsPageState extends State } return true; }, - child: Stack( - children: [ - NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return SliverSearchBar( - floating: true, - titleText: S.of(context)!.documents, - ); - } else { - return DocumentSelectionSliverAppBar( - state: state, - ); - } - }, - ), - ), - SliverOverlapAbsorber( - handle: savedViewsHandle, - sliver: SliverPinnedHeader( - child: Material( - child: _buildViewActions(), - elevation: 4, - ), - ), - ), - // SliverOverlapAbsorber( - // handle: tabBarHandle, - // sliver: BlocBuilder( - // builder: (context, state) { - // if (state.selection.isNotEmpty) { - // return const SliverToBoxAdapter( - // child: SizedBox.shrink(), - // ); - // } - // return SliverPersistentHeader( - // pinned: true, - // delegate: - // CustomizableSliverPersistentHeaderDelegate( - // minExtent: kTextTabBarHeight, - // maxExtent: kTextTabBarHeight, - // child: ColoredTabBar( - // tabBar: TabBar( - // controller: _tabController, - // tabs: [ - // Tab(text: S.of(context)!.documents), - // if (context - // .watch() - // .paperlessUser - // .canViewSavedViews) - // Tab(text: S.of(context)!.views), - // ], - // ), - // ), - // ), - // ); - // }, - // ), - // ), - ], - body: NotificationListener( - onNotification: (notification) { - final metrics = notification.metrics; - if (metrics.maxScrollExtent == 0) { - return true; + child: NestedScrollView( + key: _nestedScrollViewKey, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: BlocBuilder( + builder: (context, state) { + if (state.selection.isEmpty) { + return SliverSearchBar( + floating: true, + titleText: S.of(context)!.documents, + ); + } else { + return DocumentSelectionSliverAppBar( + state: state, + ); } - final desiredTab = - (metrics.pixels / metrics.maxScrollExtent) - .round(); - if (metrics.axis == Axis.horizontal && - _currentTab != desiredTab) { - setState(() => _currentTab = desiredTab); - } - return false; }, - child: _buildDocumentsTab( - connectivityState, - context, + ), + ), + SliverOverlapAbsorber( + handle: savedViewsHandle, + sliver: SliverPinnedHeader( + child: Material( + child: _buildViewActions(), + elevation: 2, ), ), ), - _buildSavedViewChangedIndicator(), ], + body: _buildDocumentsTab( + connectivityState, + context, + ), ), ), ), @@ -287,82 +288,6 @@ class _DocumentsPageState extends State ); } - Widget _buildSavedViewChangedIndicator() { - return BlocBuilder( - builder: (context, state) { - final savedViewCubit = context.watch(); - final activeView = savedViewCubit.state.maybeMap( - loaded: (savedViewState) { - if (state.filter.selectedView != null) { - return savedViewState.savedViews[state.filter.selectedView!]; - } - return null; - }, - orElse: () => null, - ); - final viewHasChanged = - activeView != null && activeView.toDocumentFilter() != state.filter; - return AnimatedScale( - scale: viewHasChanged ? 1 : 0, - alignment: Alignment.bottomCenter, - duration: const Duration(milliseconds: 300), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - margin: const EdgeInsets.only(bottom: 24), - child: Material( - borderRadius: BorderRadius.circular(24), - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withOpacity(0.9), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - onTap: () async { - await _updateCurrentSavedView(); - setState(() {}); - }, - child: Padding( - padding: EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - "Update selected view", - style: Theme.of(context).textTheme.labelLarge, - ), - ), - ), - ), - ), - ), - ); - }, - ); - } - - // Widget _buildSavedViewsTab( - // ConnectivityState connectivityState, - // BuildContext context, - // ) { - // return RefreshIndicator( - // edgeOffset: kTextTabBarHeight, - // onRefresh: _onReloadSavedViews, - // notificationPredicate: (_) => connectivityState.isConnected, - // child: CustomScrollView( - // key: const PageStorageKey("savedViews"), - // slivers: [ - // SliverOverlapInjector( - // handle: searchBarHandle, - // ), - // SliverOverlapInjector( - // handle: savedViewsHandle, - // ), - // const SavedViewList(), - // ], - // ), - // ); - // } - Widget _buildDocumentsTab( ConnectivityState connectivityState, BuildContext context, @@ -376,12 +301,11 @@ class _DocumentsPageState extends State _savedViewsExpansionController.collapse(); } - final currState = context.read().state; final max = notification.metrics.maxScrollExtent; + final currentState = context.read().state; if (max == 0 || - _currentTab != 0 || - currState.isLoading || - currState.isLastPageLoaded) { + currentState.isLoading || + currentState.isLastPageLoaded) { return false; } @@ -402,7 +326,7 @@ class _DocumentsPageState extends State }, child: RefreshIndicator( edgeOffset: kTextTabBarHeight + 2, - onRefresh: _onReloadDocuments, + onRefresh: _reloadData, notificationPredicate: (_) => connectivityState.isConnected, child: CustomScrollView( key: const PageStorageKey("documents"), @@ -428,8 +352,8 @@ class _DocumentsPageState extends State }, onUpdateView: (view) async { await context.read().update(view); - showSnackBar(context, - "Saved view successfully updated."); //TODO: INTL + showSnackBar( + context, S.of(context)!.savedViewSuccessfullyUpdated); }, onDeleteView: (view) async { HapticFeedback.mediumImpact(); @@ -496,7 +420,7 @@ class _DocumentsPageState extends State return BlocBuilder( builder: (context, state) { return Container( - padding: EdgeInsets.all(4), + padding: const EdgeInsets.all(4), color: Theme.of(context).colorScheme.background, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -515,18 +439,6 @@ class _DocumentsPageState extends State ); } - void _onCreateSavedView(DocumentFilter filter) async { - //TODO: Implement - // final newView = await pushAddSavedViewRoute(context, filter: filter); - // if (newView != null) { - // try { - // await context.read().add(newView); - // } on PaperlessApiException catch (error, stackTrace) { - // showErrorMessage(context, error, stackTrace); - // } - // } - } - void _openDocumentFilter() async { final draggableSheetController = DraggableScrollableController(); final filterIntent = await showModalBottomSheet( @@ -717,66 +629,46 @@ class _DocumentsPageState extends State } } - Future _onReloadDocuments() async { - try { - // We do not await here on purpose so we can show a linear progress indicator below the app bar. - await Future.wait([ - context.read().reload(), - context.read().reload(), - ]); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - + /// + /// Resets the current filter and scrolls all the way to the top of the view. + /// If a saved view is currently selected and the filter has changed, + /// the user will be shown a dialog informing them about the changes. + /// The user can then decide whether to abort the reset or to continue and discard the changes. Future _onResetFilter() async { final cubit = context.read(); final savedViewCubit = context.read(); - final activeView = savedViewCubit.state.maybeMap( + + void toTop() async { + await _nestedScrollViewKey.currentState?.outerController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + final activeView = savedViewCubit.state.mapOrNull( loaded: (state) { if (cubit.state.filter.selectedView != null) { return state.savedViews[cubit.state.filter.selectedView!]; } return null; }, - orElse: () => null, ); final viewHasChanged = activeView != null && activeView.toDocumentFilter() != cubit.state.filter; if (viewHasChanged) { - final discardChanges = await showDialog( - context: context, - builder: (context) => const SavedViewChangedDialog(), - ); - if (discardChanges == true) { + final discardChanges = await showDialog( + context: context, + builder: (context) => const SavedViewChangedDialog(), + ) ?? + false; + if (discardChanges) { cubit.resetFilter(); - // Reset - } else if (discardChanges == false) { - _updateCurrentSavedView(); + toTop(); } } else { cubit.resetFilter(); + toTop(); } } - - Future _updateCurrentSavedView() async { - final savedViewCubit = context.read(); - final cubit = context.read(); - final activeView = savedViewCubit.state.maybeMap( - loaded: (state) { - if (cubit.state.filter.selectedView != null) { - return state.savedViews[cubit.state.filter.selectedView!]; - } - return null; - }, - orElse: () => null, - ); - if (activeView == null) return; - final newView = activeView.copyWith( - filterRules: FilterRule.fromFilter(cubit.state.filter), - ); - - await savedViewCubit.update(newView); - showSnackBar(context, "Saved view successfully updated."); - } } diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 981d87bc..74822658 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -8,6 +8,7 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class DocumentsEmptyState extends StatelessWidget { final DocumentPagingState state; final VoidCallback? onReset; + const DocumentsEmptyState({ Key? key, required this.state, @@ -17,18 +18,24 @@ class DocumentsEmptyState extends StatelessWidget { @override Widget build(BuildContext context) { return Center( - child: EmptyState( - title: S.of(context)!.oops, - subtitle: S.of(context)!.thereSeemsToBeNothingHere, - bottomChild: state.filter != DocumentFilter.initial && onReset != null - ? TextButton( - onPressed: onReset, - child: Text( - S.of(context)!.resetFilter, - ), - ).padded() - : null, - ), + child: Column( + children: [ + Text( + S.of(context)!.noDocumentsFound, + style: Theme.of(context).textTheme.titleSmall, + ), + if (state.filter != DocumentFilter.initial && onReset != null) + TextButton( + onPressed: () { + HapticFeedback.mediumImpact(); + onReset!(); + }, + child: Text( + S.of(context)!.resetFilter, + ), + ).padded(), + ], + ).padded(24), ); } } diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart index 32700cdd..116eb373 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart @@ -9,19 +9,11 @@ class SavedViewChangedDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text("Discard changes?"), //TODO: INTL - content: Text( - "Some filters of the currently active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", //TODO: INTL - ), + title: Text(S.of(context)!.discardChanges), + content: Text(S.of(context)!.savedViewChangedDialogContent), actionsOverflowButtonSpacing: 8, actions: [ const DialogCancelButton(), - // TextButton( - // child: Text(S.of(context)!.saveChanges), - // onPressed: () { - // Navigator.pop(context, false); - // }, - // ), DialogConfirmButton( label: S.of(context)!.resetFilter, style: DialogConfirmButtonStyle.danger, diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart index 23026e33..12dbd3b1 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; class SavedViewChip extends StatefulWidget { @@ -102,7 +101,6 @@ class _SavedViewChipState extends State _buildLabel(context, effectiveForegroundColor) .paddedSymmetrically( horizontal: 12, - vertical: 0, ), ], ).paddedOnly(left: 8), @@ -120,6 +118,7 @@ class _SavedViewChipState extends State Widget _buildTrailing(Color effectiveForegroundColor) { return IconButton( + padding: EdgeInsets.zero, icon: AnimatedBuilder( animation: _animation, builder: (context, child) { diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 20acd058..3bf926f2 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; +import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; @@ -46,99 +46,185 @@ class _SavedViewsWidgetState extends State @override Widget build(BuildContext context) { - return PageStorage( - bucket: PageStorageBucket(), - child: ExpansionTile( - controller: widget.controller, - tilePadding: const EdgeInsets.only(left: 8), - trailing: RotationTransition( - turns: _animation, - child: const Icon(Icons.expand_more), - ).paddedOnly(right: 8), - onExpansionChanged: (isExpanded) { - if (isExpanded) { - _animationController.forward(); - } else { - _animationController.reverse().then((value) => setState(() {})); - } - }, - title: Text( - S.of(context)!.views, - style: Theme.of(context).textTheme.labelLarge, - ), - leading: Icon( - Icons.saved_search, - color: Theme.of(context).colorScheme.primary, - ).padded(), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, state) { - return state.map( - initial: (_) => const Placeholder(), - loading: (_) => const Placeholder(), - loaded: (value) { - if (value.savedViews.isEmpty) { - return Text(S.of(context)!.noItemsFound) - .paddedOnly(left: 16); - } - return Container( - margin: EdgeInsets.only(top: 16), - height: kMinInteractiveDimension, - child: NotificationListener( - onNotification: (notification) => true, - child: CustomScrollView( - scrollDirection: Axis.horizontal, - slivers: [ - const SliverToBoxAdapter( - child: SizedBox(width: 12), - ), - SliverList.separated( - itemBuilder: (context, index) { - final view = - value.savedViews.values.elementAt(index); - final isSelected = - (widget.filter.selectedView ?? -1) == view.id; - return SavedViewChip( - view: view, - onViewSelected: widget.onViewSelected, - selected: isSelected, - hasChanged: isSelected && - view.toDocumentFilter() != widget.filter, - onUpdateView: widget.onUpdateView, - onDeleteView: widget.onDeleteView, - ); - }, - separatorBuilder: (context, index) => - const SizedBox(width: 8), - itemCount: value.savedViews.length, - ), - const SliverToBoxAdapter( - child: SizedBox(width: 12), - ), - ], + return BlocBuilder( + builder: (context, state) { + final selectedView = state.mapOrNull( + loaded: (value) { + if (widget.filter.selectedView != null) { + return value.savedViews[widget.filter.selectedView!]; + } + }, + ); + final selectedViewHasChanged = selectedView != null && + selectedView.toDocumentFilter() != widget.filter; + return PageStorage( + bucket: PageStorageBucket(), + child: ExpansionTile( + controller: widget.controller, + tilePadding: const EdgeInsets.only(left: 8), + trailing: RotationTransition( + turns: _animation, + child: const Icon(Icons.expand_more), + ).paddedOnly(right: 8), + onExpansionChanged: (isExpanded) { + if (isExpanded) { + _animationController.forward(); + } else { + _animationController.reverse().then((value) => setState(() {})); + } + }, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context)!.views, + style: Theme.of(context).textTheme.labelLarge, ), + if (selectedView != null) + Text( + selectedView.name, + style: + Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.5), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + AnimatedScale( + scale: selectedViewHasChanged ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: TextButton( + onPressed: () { + final newView = selectedView!.copyWith( + filterRules: FilterRule.fromFilter(widget.filter), + ); + widget.onUpdateView(newView); + }, + child: Text(S.of(context)!.saveChanges), + ), + ) + ], + ), + leading: Icon( + Icons.saved_search, + color: Theme.of(context).colorScheme.primary, + ).padded(), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + state + .maybeMap( + loaded: (value) { + if (value.savedViews.isEmpty) { + return Text(S.of(context)!.noItemsFound) + .paddedOnly(left: 16); + } + + return SizedBox( + height: kMinInteractiveDimension, + child: NotificationListener( + onNotification: (notification) => true, + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + SliverList.separated( + itemBuilder: (context, index) { + final view = + value.savedViews.values.elementAt(index); + final isSelected = + (widget.filter.selectedView ?? -1) == + view.id; + return SavedViewChip( + view: view, + onViewSelected: widget.onViewSelected, + selected: isSelected, + hasChanged: isSelected && + view.toDocumentFilter() != + widget.filter, + onUpdateView: widget.onUpdateView, + onDeleteView: widget.onDeleteView, + ); + }, + separatorBuilder: (context, index) => + const SizedBox(width: 8), + itemCount: value.savedViews.length, + ), + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + ], + ), + ), + ); + }, + error: (_) => Text(S.of(context)!.couldNotLoadSavedViews) + .paddedOnly(left: 16), + orElse: _buildLoadingState, + ) + .paddedOnly(top: 16), + Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: S.of(context)!.createFromCurrentFilter, + child: TextButton.icon( + onPressed: () { + CreateSavedViewRoute(widget.filter).push(context); + }, + icon: const Icon(Icons.add), + label: Text(S.of(context)!.newView), + ), + ).padded(4), + ), + ], + ), + ); + }, + ); + } + + Widget _buildLoadingState() { + return Container( + margin: const EdgeInsets.only(top: 16), + height: kMinInteractiveDimension, + child: NotificationListener( + onNotification: (notification) => true, + child: ShimmerPlaceholder( + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + SliverList.separated( + itemBuilder: (context, index) { + return Container( + width: 130, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white, ), ); }, - error: (_) => const Placeholder(), - ); - }, - ), - Align( - alignment: Alignment.centerRight, - child: Tooltip( - message: "Create from current filter", //TODO: INTL - child: TextButton.icon( - onPressed: () { - CreateSavedViewRoute(widget.filter).push(context); - }, - icon: const Icon(Icons.add), - label: Text(S.of(context)!.newView), + separatorBuilder: (context, index) => const SizedBox(width: 8), ), - ).padded(4), + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + ], ), - ], + ), ), ); } diff --git a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart index 52874b91..aa85dea8 100644 --- a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; diff --git a/lib/features/edit_label/view/edit_label_page.dart b/lib/features/edit_label/view/edit_label_page.dart index 9ed93695..c73cef2e 100644 --- a/lib/features/edit_label/view/edit_label_page.dart +++ b/lib/features/edit_label/view/edit_label_page.dart @@ -2,15 +2,16 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.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/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/features/edit_label/cubit/edit_label_cubit.dart'; import 'package:paperless_mobile/features/edit_label/view/label_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; class EditLabelPage extends StatelessWidget { @@ -56,8 +57,9 @@ class EditLabelForm extends StatelessWidget { final Future Function(BuildContext context, T label) onSubmit; final Future Function(BuildContext context, T label) onDelete; final bool canDelete; + final _formKey = GlobalKey(); - const EditLabelForm({ + EditLabelForm({ super.key, required this.label, required this.fromJsonT, @@ -69,26 +71,32 @@ class EditLabelForm extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.edit), - actions: [ - IconButton( - onPressed: canDelete ? () => _onDelete(context) : null, - icon: const Icon(Icons.delete), + return PopWithUnsavedChanges( + hasChangesPredicate: () { + return _formKey.currentState?.isDirty ?? false; + }, + child: Scaffold( + appBar: AppBar( + title: Text(S.of(context)!.edit), + actions: [ + IconButton( + onPressed: canDelete ? () => _onDelete(context) : null, + icon: const Icon(Icons.delete), + ), + ], + ), + body: LabelForm( + formKey: _formKey, + autofocusNameField: false, + initialValue: label, + fromJsonT: fromJsonT, + submitButtonConfig: SubmitButtonConfig( + icon: const Icon(Icons.save), + label: Text(S.of(context)!.saveChanges), + onSubmit: (label) => onSubmit(context, label), ), - ], - ), - body: LabelForm( - autofocusNameField: false, - initialValue: label, - fromJsonT: fromJsonT, - submitButtonConfig: SubmitButtonConfig( - icon: const Icon(Icons.save), - label: Text(S.of(context)!.saveChanges), - onSubmit: (label) => onSubmit(context, label), + additionalFields: additionalFields, ), - additionalFields: additionalFields, ), ); } diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index 901e2ca1..0ffd4c92 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -2,14 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; - import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; class SubmitButtonConfig { @@ -36,6 +33,7 @@ class LabelForm extends StatefulWidget { final List additionalFields; final bool autofocusNameField; + final GlobalKey? formKey; const LabelForm({ Key? key, @@ -44,6 +42,7 @@ class LabelForm extends StatefulWidget { this.additionalFields = const [], required this.submitButtonConfig, required this.autofocusNameField, + this.formKey, }) : super(key: key); @override @@ -51,7 +50,7 @@ class LabelForm extends StatefulWidget { } class _LabelFormState extends State> { - final _formKey = GlobalKey(); + late final GlobalKey _formKey; late bool _enableMatchFormField; @@ -60,6 +59,7 @@ class _LabelFormState extends State> { @override void initState() { super.initState(); + _formKey = widget.formKey ?? GlobalKey(); var matchingAlgorithm = (widget.initialValue?.matchingAlgorithm ?? MatchingAlgorithm.defaultValue); _enableMatchFormField = matchingAlgorithm != MatchingAlgorithm.auto && diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index de636d2e..24198787 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -17,14 +16,9 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; -import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:provider/provider.dart'; class HomeShellWidget extends StatelessWidget { diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index afc50b06..1d8aacd0 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/theme.dart'; const _landingPage = 0; const _documentsIndex = 1; @@ -28,96 +30,105 @@ class ScaffoldWithNavigationBar extends StatefulWidget { class ScaffoldWithNavigationBarState extends State { @override - Widget build(BuildContext context) { - final primaryColor = Theme.of(context).colorScheme.primary; + void didChangeDependencies() { + super.didChangeDependencies(); + } - return Scaffold( - drawer: const AppDrawer(), - bottomNavigationBar: NavigationBar( - selectedIndex: widget.navigationShell.currentIndex, - onDestinationSelected: (index) { - widget.navigationShell.goBranch( - index, - initialLocation: index == widget.navigationShell.currentIndex, - ); - }, - destinations: [ - NavigationDestination( - icon: const Icon(Icons.home_outlined), - selectedIcon: Icon( - Icons.home, - color: primaryColor, - ), - label: "Home", //TODO: INTL - ), - _toggleDestination( + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnnotatedRegion( + value: buildOverlayStyle(theme), + child: Scaffold( + drawer: const AppDrawer(), + bottomNavigationBar: NavigationBar( + elevation: 3, + backgroundColor: Theme.of(context).colorScheme.surface, + selectedIndex: widget.navigationShell.currentIndex, + onDestinationSelected: (index) { + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); + }, + destinations: [ NavigationDestination( - icon: const Icon(Icons.description_outlined), + icon: const Icon(Icons.home_outlined), selectedIcon: Icon( - Icons.description, - color: primaryColor, + Icons.home, + color: theme.colorScheme.primary, ), - label: S.of(context)!.documents, + label: S.of(context)!.home, ), - disableWhen: !widget.authenticatedUser.canViewDocuments, - ), - _toggleDestination( - NavigationDestination( - icon: const Icon(Icons.document_scanner_outlined), - selectedIcon: Icon( - Icons.document_scanner, - color: primaryColor, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.description_outlined), + selectedIcon: Icon( + Icons.description, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.documents, ), - label: S.of(context)!.scanner, + disableWhen: !widget.authenticatedUser.canViewDocuments, ), - disableWhen: !widget.authenticatedUser.canCreateDocuments, - ), - _toggleDestination( - NavigationDestination( - icon: const Icon(Icons.sell_outlined), - selectedIcon: Icon( - Icons.sell, - color: primaryColor, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.document_scanner_outlined), + selectedIcon: Icon( + Icons.document_scanner, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.scanner, ), - label: S.of(context)!.labels, + disableWhen: !widget.authenticatedUser.canCreateDocuments, ), - disableWhen: !widget.authenticatedUser.canViewAnyLabel, - ), - _toggleDestination( - NavigationDestination( - icon: Builder( - builder: (context) { - return BlocBuilder( - builder: (context, state) { - return Badge.count( - isLabelVisible: state.itemsInInboxCount > 0, - count: state.itemsInInboxCount, - child: const Icon(Icons.inbox_outlined), - ); - }, - ); - }, + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.sell_outlined), + selectedIcon: Icon( + Icons.sell, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.labels, ), - selectedIcon: BlocBuilder( - builder: (context, state) { - return Badge.count( - isLabelVisible: state.itemsInInboxCount > 0 && - widget.authenticatedUser.canViewInbox, - count: state.itemsInInboxCount, - child: Icon( - Icons.inbox, - color: primaryColor, - ), - ); - }, + disableWhen: !widget.authenticatedUser.canViewAnyLabel, + ), + _toggleDestination( + NavigationDestination( + icon: Builder( + builder: (context) { + return BlocBuilder( + builder: (context, state) { + return Badge.count( + isLabelVisible: state.itemsInInboxCount > 0, + count: state.itemsInInboxCount, + child: const Icon(Icons.inbox_outlined), + ); + }, + ); + }, + ), + selectedIcon: BlocBuilder( + builder: (context, state) { + return Badge.count( + isLabelVisible: state.itemsInInboxCount > 0 && + widget.authenticatedUser.canViewInbox, + count: state.itemsInInboxCount, + child: Icon( + Icons.inbox, + color: theme.colorScheme.primary, + ), + ); + }, + ), + label: S.of(context)!.inbox, ), - label: S.of(context)!.inbox, + disableWhen: !widget.authenticatedUser.canViewInbox, ), - disableWhen: !widget.authenticatedUser.canViewInbox, - ), - ], + ], + ), + body: widget.navigationShell, ), - body: widget.navigationShell, ); } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index c1638f2a..ef65bfa7 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -33,8 +33,40 @@ class _InboxPageState extends State @override final pagingScrollController = ScrollController(); + final _nestedScrollViewKey = GlobalKey(); final _emptyStateRefreshIndicatorKey = GlobalKey(); final _scrollController = ScrollController(); + bool _showExtendedFab = true; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _nestedScrollViewKey.currentState!.innerController + .addListener(_scrollExtentChangedListener); + }); + } + + @override + void dispose() { + _nestedScrollViewKey.currentState?.innerController + .removeListener(_scrollExtentChangedListener); + super.dispose(); + } + + void _scrollExtentChangedListener() { + const threshold = 400; + final offset = + _nestedScrollViewKey.currentState!.innerController.position.pixels; + if (offset < threshold && _showExtendedFab == false) { + setState(() { + _showExtendedFab = true; + }); + } else if (offset >= threshold && _showExtendedFab == true) { + setState(() { + _showExtendedFab = false; + }); + } + } @override Widget build(BuildContext context) { @@ -48,9 +80,31 @@ class _InboxPageState extends State return const SizedBox.shrink(); } return FloatingActionButton.extended( - heroTag: "fab_inbox", - label: Text(S.of(context)!.allSeen), - icon: const Icon(Icons.done_all), + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric(horizontal: 16), + heroTag: "inbox_page_fab", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon(Icons.done_all), + Text(S.of(context)!.allSeen), + ], + ) + : const Icon(Icons.done_all), + ), onPressed: state.hasLoaded && state.documents.isNotEmpty ? () => _onMarkAllAsSeen( state.documents, @@ -63,13 +117,9 @@ class _InboxPageState extends State body: SafeArea( top: true, child: NestedScrollView( + key: _nestedScrollViewKey, headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: SliverSearchBar( - titleText: S.of(context)!.inbox, - ), - ) + SliverSearchBar(titleText: S.of(context)!.inbox), ], body: BlocBuilder( builder: (_, state) { diff --git a/lib/features/labels/cubit/label_cubit.dart b/lib/features/labels/cubit/label_cubit.dart index 2edc2ac9..c8d6d5f6 100644 --- a/lib/features/labels/cubit/label_cubit.dart +++ b/lib/features/labels/cubit/label_cubit.dart @@ -4,8 +4,8 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit_mixin.dart'; -part 'label_state.dart'; part 'label_cubit.freezed.dart'; +part 'label_state.dart'; class LabelCubit extends Cubit with LabelCubitMixin { @override @@ -25,6 +25,15 @@ class LabelCubit extends Cubit with LabelCubitMixin { ); } + Future reload() { + return Future.wait([ + labelRepository.findAllCorrespondents(), + labelRepository.findAllDocumentTypes(), + labelRepository.findAllTags(), + labelRepository.findAllStoragePaths(), + ]); + } + @override Future close() { labelRepository.removeListener(this); diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index ff7b39c9..896fcea2 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -30,6 +30,7 @@ class _LabelsPageState extends State SliverOverlapAbsorberHandle(); late final TabController _tabController; + int _currentIndex = 0; int _calculateTabCount(UserModel user) => [ @@ -48,6 +49,12 @@ class _LabelsPageState extends State ..addListener(() => setState(() => _currentIndex = _tabController.index)); } + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -65,8 +72,17 @@ class _LabelsPageState extends State return SafeArea( child: Scaffold( drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - heroTag: "fab_labels_page", + floatingActionButton: FloatingActionButton.extended( + heroTag: "inbox_page_fab", + label: Text( + [ + S.of(context)!.addCorrespondent, + S.of(context)!.addDocumentType, + S.of(context)!.addTag, + S.of(context)!.addStoragePath, + ][_currentIndex], + ), + icon: Icon(Icons.add), onPressed: [ if (user.canViewCorrespondents) () => CreateLabelRoute(LabelType.correspondent) @@ -80,7 +96,6 @@ class _LabelsPageState extends State () => CreateLabelRoute(LabelType.storagePath) .push(context), ][_currentIndex], - child: const Icon(Icons.add), ), body: NestedScrollView( floatHeaderSlivers: true, diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index a41e2b6a..da6e340d 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -42,7 +42,8 @@ class _LandingPageState extends State { slivers: [ SliverToBoxAdapter( child: Text( - "Welcome, ${currentUser.fullName ?? currentUser.username}!", + S.of(context)!.welcomeUser( + currentUser.fullName ?? currentUser.username), textAlign: TextAlign.center, style: Theme.of(context) .textTheme @@ -81,13 +82,12 @@ class _LandingPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "There are no saved views to show on your dashboard.", //TODO: INTL - ).padded(), + Text(S.of(context)!.noSavedViewOnHomepageHint) + .padded(), TextButton.icon( onPressed: () {}, icon: const Icon(Icons.add), - label: Text("Add new view"), + label: Text(S.of(context)!.newView), ) ], ).paddedOnly(left: 16), @@ -121,35 +121,23 @@ class _LandingPageState extends State { Widget _buildStatisticsCard(BuildContext context) { final currentUser = context.read().paperlessUser; - return FutureBuilder( - future: context.read().getServerStatistics(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Card( - margin: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Statistics", //TODO: INTL - style: Theme.of(context).textTheme.titleLarge, - ), - const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), - ), - ], - ).padded(16), - ); - } - final stats = snapshot.data!; - return ExpansionCard( - initiallyExpanded: false, - title: Text( - "Statistics", //TODO: INTL - style: Theme.of(context).textTheme.titleLarge, - ), - content: Column( + + return ExpansionCard( + initiallyExpanded: false, + title: Text( + S.of(context)!.statistics, + style: Theme.of(context).textTheme.titleLarge, + ), + content: FutureBuilder( + future: context.read().getServerStatistics(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ).paddedOnly(top: 8, bottom: 24); + } + final stats = snapshot.data!; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Card( @@ -157,7 +145,7 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: const Text("Documents in inbox:"), + title: Text(S.of(context)!.documentsInInbox), onTap: currentUser.canViewTags && currentUser.canViewDocuments ? () => InboxRoute().go(context) : null, @@ -172,19 +160,13 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: const Text("Total documents:"), + title: Text(S.of(context)!.totalDocuments), onTap: () { DocumentsRoute().go(context); }, - trailing: Chip( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - labelPadding: const EdgeInsets.symmetric(horizontal: 4), - label: Text( - stats.documentsTotal.toString(), - ), + trailing: Text( + stats.documentsTotal.toString(), + style: Theme.of(context).textTheme.labelLarge, ), ), ), @@ -193,16 +175,10 @@ class _LandingPageState extends State { child: ListTile( shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, - title: const Text("Total characters:"), - trailing: Chip( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - labelPadding: const EdgeInsets.symmetric(horizontal: 4), - label: Text( - stats.totalChars.toString(), - ), + title: Text(S.of(context)!.totalCharacters), + trailing: Text( + stats.totalChars.toString(), + style: Theme.of(context).textTheme.labelLarge, ), ), ), @@ -214,9 +190,9 @@ class _LandingPageState extends State { ), ), ], - ).padded(16), - ); - }, + ).padded(16); + }, + ), ); } } diff --git a/lib/features/landing/view/widgets/expansion_card.dart b/lib/features/landing/view/widgets/expansion_card.dart index f27affd9..fda18706 100644 --- a/lib/features/landing/view/widgets/expansion_card.dart +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -15,6 +15,7 @@ class ExpansionCard extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Card( margin: const EdgeInsets.all(16), child: Theme( @@ -29,8 +30,17 @@ class ExpansionCard extends StatelessWidget { ), ), child: ExpansionTile( - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: ElevationOverlay.applySurfaceTint( + colorScheme.surface, + colorScheme.surfaceTint, + 4, + ), initiallyExpanded: initiallyExpanded, + collapsedBackgroundColor: ElevationOverlay.applySurfaceTint( + colorScheme.surface, + colorScheme.surfaceTint, + 4, + ), title: title, children: [content], ), diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 76b5c247..8e8b56d1 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -16,6 +16,7 @@ import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; part 'authentication_cubit.freezed.dart'; part 'authentication_state.dart'; @@ -196,8 +197,11 @@ class AuthenticationCubit extends Cubit { "restoreSessionState", "Biometric authentication required, waiting for user to authenticate...", ); - final localAuthSuccess = await _localAuthService - .authenticateLocalUser("Authenticate to log back in"); //TODO: INTL + final authenticationMesage = + (await S.delegate.load(Locale(globalSettings.preferredLocaleSubtag))) + .verifyYourIdentity; + final localAuthSuccess = + await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { emit(const AuthenticationState.requriresLocalAuthentication()); _debugPrintMessage( @@ -233,7 +237,7 @@ class AuthenticationCubit extends Cubit { ); throw Exception( "User should be authenticated but no authentication information was found.", - ); //TODO: INTL + ); } _debugPrintMessage( "restoreSessionState", diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index 9f69782d..e651bb9a 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -130,7 +130,7 @@ class LocalNotificationService { filePath: filePath, ).toJson(), ), - ); //TODO: INTL + ); } diff --git a/lib/features/saved_view/cubit/saved_view_cubit.dart b/lib/features/saved_view/cubit/saved_view_cubit.dart index c35d2357..a2c255b7 100644 --- a/lib/features/saved_view/cubit/saved_view_cubit.dart +++ b/lib/features/saved_view/cubit/saved_view_cubit.dart @@ -5,8 +5,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; -part 'saved_view_state.dart'; part 'saved_view_cubit.freezed.dart'; +part 'saved_view_state.dart'; class SavedViewCubit extends Cubit { final SavedViewRepository _savedViewRepository; diff --git a/lib/features/saved_view/view/add_saved_view_page.dart b/lib/features/saved_view/view/add_saved_view_page.dart index d368ed7c..78ca3904 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 638855c7..38a1488c 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -28,53 +28,57 @@ class SavedViewPreview extends StatelessWidget { return ExpansionCard( initiallyExpanded: expanded, title: Text(savedView.name), - content: BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - loaded: (documents) { - return Column( - children: [ - if (documents.isEmpty) - Text("This view does not match any documents.").padded() - else - for (final document in documents) - DocumentListItem( - document: document, - isLabelClickable: false, - isSelected: false, - isSelectionActive: false, - onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); - }, - onSelected: null, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - icon: const Icon(Icons.open_in_new), - label: Text("Show all"), //TODO: INTL - onPressed: () { - context.read().updateFilter( - filter: savedView.toDocumentFilter(), - ); - DocumentsRoute().go(context); - }, - ), - ], - ), - ], + content: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + loaded: (documents) { + if (documents.isEmpty) { + return Text(S.of(context)!.noDocumentsFound).padded(); + } else { + return Column( + children: [ + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + onSelected: null, + ), + ], + ); + } + }, + error: () => Text(S.of(context)!.couldNotLoadSavedViews), + orElse: () => const Center( + child: CircularProgressIndicator(), + ).paddedOnly(top: 8, bottom: 24), ); }, - error: () => - const Text("Could not load saved view."), //TODO: INTL - orElse: () => const Padding( - padding: EdgeInsets.all(8.0), - child: Center(child: CircularProgressIndicator()), - ), - ); - }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + icon: const Icon(Icons.open_in_new), + label: Text(S.of(context)!.showAll), + onPressed: () { + context.read().updateFilter( + filter: savedView.toDocumentFilter(), + ); + DocumentsRoute().go(context); + }, + ).paddedOnly(bottom: 8), + ], + ), + ], ), ); }, diff --git a/lib/features/settings/view/widgets/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart index 18c35cba..4bb28146 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; @@ -8,6 +9,7 @@ import 'package:paperless_mobile/features/settings/model/color_scheme_option.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/theme.dart'; class ColorSchemeOptionSetting extends StatelessWidget { const ColorSchemeOptionSetting({super.key}); @@ -52,10 +54,10 @@ class ColorSchemeOptionSetting extends StatelessWidget { initialValue: settings.preferredColorSchemeOption, ), ).then( - (value) { + (value) async { if (value != null) { settings.preferredColorSchemeOption = value; - settings.save(); + await settings.save(); } }, ), diff --git a/lib/features/settings/view/widgets/theme_mode_setting.dart b/lib/features/settings/view/widgets/theme_mode_setting.dart index 50c27005..43d4c7cf 100644 --- a/lib/features/settings/view/widgets/theme_mode_setting.dart +++ b/lib/features/settings/view/widgets/theme_mode_setting.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/theme.dart'; class ThemeModeSetting extends StatelessWidget { const ThemeModeSetting({super.key}); @@ -34,10 +36,10 @@ class ThemeModeSetting extends StatelessWidget { ) ], ), - ).then((value) { + ).then((value) async { if (value != null) { settings.preferredThemeMode = value; - settings.save(); + await settings.save(); } }), ); diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index c9eb7b57..e84be1e2 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Vols esborrar aquesta vista?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Esborra Vista ", + "deleteView": "Esborra Vista {name}?", "@deleteView": {}, "addedAt": "Afegit", "@addedAt": {}, @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 4b7caf50..265d2db9 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Opravdu chceš tento náhled smazat?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Smazat náhled ", + "deleteView": "Smazat náhled {name}?", "@deleteView": {}, "addedAt": "Přidáno", "@addedAt": {}, @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index f2af3580..730b4b91 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Möchtest Du diese Ansicht wirklich löschen?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Lösche Ansicht ", + "deleteView": "Ansicht {name} löschen?", "@deleteView": {}, "addedAt": "Hinzugefügt am", "@addedAt": {}, @@ -876,5 +876,98 @@ "donationDialogContent": "Vielen Dank, dass Du diese App unterstützen möchtest! Aufgrund der Zahlungsrichtlinien von Google und Apple dürfen keine Links, die zu Spendenseiten führen, in der App angezeigt werden. Nicht einmal die Verlinkung zur Repository-Seite des Projekts scheint in diesem Zusammenhang erlaubt zu sein. Werfe von daher vielleicht einen Blick auf den Abschnitt 'Donations' in der README des Projekts. Deine Unterstützung ist sehr willkommen und hält die Entwicklung dieser App am Leben. Vielen Dank!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "Keine Dokumente gefunden.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Korrespondent konnte nicht gelöscht werden, bitte versuche es erneut.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Dokumenttyp konnten nicht gelöscht werden, bitte versuche es erneut.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Tag konnte nicht gelöscht werden, bitte versuche es erneut.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Speicherpfad konnte nicht gelöscht werden, bitte versuchen Sie es erneut.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Korrespondent konnte nicht aktualisiert werden, bitte versuche es erneut.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Dokumenttyp konnte nicht aktualisiert werden, bitte versuche es erneut.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Tag konnte nicht aktualisiert werden, bitte versuche es erneut.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Serverinformationen konnten nicht geladen werden.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Serverstatistiken konnten nicht geladen werden.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "UI Einstellungen konnten nicht geladen werden.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Dateiaufgaben konnten nicht geladen werden.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "Der Nutzer konnte nicht gefunden werden.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Ansicht konnte nicht aktualisiert werden, bitte versuche es erneut.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Speicherpfad konnte nicht aktualisiert werden, bitte versuchen Sie es erneut.", + "savedViewSuccessfullyUpdated": "Ansicht erfolgreich aktualisiert.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Änderungen verwerfen?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "Die Filterbedingungen der aktiven Ansicht haben sich geändert. Durch Zurücksetzen des aktuellen Filters gehen diese Änderungen verloren. Möchtest du trotzdem fortfahren?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Vom aktuellen Filter erstellen", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Startseite", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Willkommen, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Konfiguriere eine Ansicht so, dass sie auf deiner Startseite angezeigt wird und sie wird hier erscheinen.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistiken", + "documentsInInbox": "Dokumente im Posteingang", + "totalDocuments": "Dokumente insgesamt", + "totalCharacters": "Zeichen insgesamt", + "showAll": "Alle anzeigen", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 5a397fc0..f3acf6b9 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Do you really want to delete this view?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Delete view ", + "deleteView": "Delete view {name}?", "@deleteView": {}, "addedAt": "Added at", "@addedAt": {}, @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 42abaa2c..faf5a53b 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 7e488cee..ad032a2d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Voulez-vous vraiment supprimer cette vue enregistrée ?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Supprimer la vue enregistrée ", + "deleteView": "Supprimer la vue enregistrée {name}?", "@deleteView": {}, "addedAt": "Date d’ajout", "@addedAt": {}, @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 99b1d8a0..d024a271 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index aa3d58b7..edd90c20 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index acebfb54..dd1b8748 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -876,5 +876,98 @@ "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "No documents found.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Could not delete tag, please try again.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Could not update tag, please try again.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Could not load server information.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Could not load server statistics.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Could not load UI settings.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Could not load tasks.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "User could not be found.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Could not update storage path, please try again.", + "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Discard changes?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "@savedViewChangedDialogContent": { + "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." + }, + "createFromCurrentFilter": "Create from current filter", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Home", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Welcome, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Statistics", + "documentsInInbox": "Documents in inbox", + "totalDocuments": "Total documents", + "totalCharacters": "Total characters", + "showAll": "Show all", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 9d7b7404..a39e7daf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -34,7 +35,6 @@ import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; -import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; @@ -109,7 +109,6 @@ void main() async { if (Platform.isIOS) { iosInfo = await DeviceInfoPlugin().iosInfo; } - final connectivity = Connectivity(); final localAuthentication = LocalAuthentication(); final connectivityStatusService = @@ -149,6 +148,7 @@ void main() async { final authenticationCubit = AuthenticationCubit(localAuthService, apiFactory, sessionManager); await authenticationCubit.restoreSessionState(); + runApp( MultiProvider( providers: [ @@ -228,11 +228,11 @@ class _GoRouterShellState extends State { $loginRoute, $verifyIdentityRoute, $switchingAccountsRoute, - $settingsRoute, ShellRoute( navigatorKey: rootNavigatorKey, builder: ProviderShellRoute(widget.apiFactory).build, routes: [ + $settingsRoute, $savedViewsRoute, StatefulShellRoute( navigatorContainerBuilder: (context, navigationShell, children) { diff --git a/lib/routes/navigation_keys.dart b/lib/routes/navigation_keys.dart index fc6cbd41..220cd4db 100644 --- a/lib/routes/navigation_keys.dart +++ b/lib/routes/navigation_keys.dart @@ -6,3 +6,4 @@ final documentsNavigatorKey = GlobalKey(); final scannerNavigatorKey = GlobalKey(); final labelsNavigatorKey = GlobalKey(); final inboxNavigatorKey = GlobalKey(); +final settingsNavigatorKey = GlobalKey(); diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index 556f9423..d753128e 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -14,6 +15,7 @@ import 'package:paperless_mobile/features/documents/view/pages/documents_page.da import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; +import 'package:paperless_mobile/theme.dart'; part 'documents_route.g.dart'; @@ -92,14 +94,21 @@ class EditDocumentRoute extends GoRouteData { @override Widget build(BuildContext context, GoRouterState state) { - return BlocProvider( - create: (context) => DocumentEditCubit( - context.read(), - context.read(), - context.read(), - document: $extra, - )..loadFieldSuggestions(), - child: const DocumentEditPage(), + final theme = Theme.of(context); + return AnnotatedRegion( + value: buildOverlayStyle( + theme, + systemNavigationBarColor: theme.colorScheme.background, + ), + child: BlocProvider( + create: (context) => DocumentEditCubit( + context.read(), + context.read(), + context.read(), + document: $extra, + )..loadFieldSuggestions(), + child: const DocumentEditPage(), + ), ); } } diff --git a/lib/routes/typed/branches/landing_route.dart b/lib/routes/typed/branches/landing_route.dart index 78c58bae..1b501c64 100644 --- a/lib/routes/typed/branches/landing_route.dart +++ b/lib/routes/typed/branches/landing_route.dart @@ -15,12 +15,6 @@ class LandingBranch extends StatefulShellBranchData { @TypedGoRoute( path: "/landing", name: R.landing, - routes: [ - TypedGoRoute( - path: "saved-view", - name: R.savedView, - ), - ], ) class LandingRoute extends GoRouteData { const LandingRoute(); @@ -29,10 +23,3 @@ class LandingRoute extends GoRouteData { return const LandingPage(); } } - -class SavedViewRoute extends GoRouteData { - @override - Widget build(BuildContext context, GoRouterState state) { - return Placeholder(); - } -} diff --git a/lib/routes/typed/top_level/settings_route.dart b/lib/routes/typed/top_level/settings_route.dart index 0b0664ce..3286c00d 100644 --- a/lib/routes/typed/top_level/settings_route.dart +++ b/lib/routes/typed/top_level/settings_route.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_mobile/features/settings/view/settings_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; +import 'package:paperless_mobile/theme.dart'; part 'settings_route.g.dart'; @@ -10,8 +13,16 @@ part 'settings_route.g.dart'; name: R.settings, ) class SettingsRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + @override Widget build(BuildContext context, GoRouterState state) { - return const SettingsPage(); + return AnnotatedRegion( + value: buildOverlayStyle( + Theme.of(context), + systemNavigationBarColor: Theme.of(context).colorScheme.background, + ), + child: const SettingsPage(), + ); } } diff --git a/lib/theme.dart b/lib/theme.dart index 34c37312..31d03a79 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -1,5 +1,6 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; const _classicThemeColorSeed = Colors.lightGreen; @@ -46,6 +47,12 @@ ThemeData buildTheme({ colorScheme: colorScheme.harmonized(), useMaterial3: true, ).copyWith( + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colorScheme.surface, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + ), cardTheme: _defaultCardTheme, inputDecorationTheme: _defaultInputDecorationTheme, listTileTheme: _defaultListTileTheme, @@ -60,3 +67,29 @@ ThemeData buildTheme({ ), ); } + +SystemUiOverlayStyle buildOverlayStyle( + ThemeData theme, { + Color? systemNavigationBarColor, +}) { + final color = systemNavigationBarColor ?? + ElevationOverlay.applySurfaceTint( + theme.colorScheme.surface, + theme.colorScheme.surfaceTint, + 3, + ); + return switch (theme.brightness) { + Brightness.light => SystemUiOverlayStyle.dark.copyWith( + systemNavigationBarColor: color, + systemNavigationBarDividerColor: color, + // statusBarColor: theme.colorScheme.background, + // systemNavigationBarDividerColor: theme.colorScheme.surface, + ), + Brightness.dark => SystemUiOverlayStyle.light.copyWith( + systemNavigationBarColor: color, + systemNavigationBarDividerColor: color, + // statusBarColor: theme.colorScheme.background, + // systemNavigationBarDividerColor: theme.colorScheme.surface, + ), + }; +} diff --git a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart index 88512e57..8b22ee3a 100644 --- a/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/saved_views_api/paperless_saved_views_api_impl.dart @@ -16,7 +16,7 @@ class PaperlessSavedViewsApiImpl implements PaperlessSavedViewsApi { @override Future> findAll([Iterable? ids]) async { final result = await getCollection( - "/api/saved_views/", + "/api/saved_views/?page_size=100000", SavedView.fromJson, ErrorCode.loadSavedViewsError, client: _client, diff --git a/pubspec.lock b/pubspec.lock index 25656cd0..986c05e4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -345,6 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.8" + defer_pointer: + dependency: "direct main" + description: + name: defer_pointer + sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02 + url: "https://pub.dev" + source: hosted + version: "0.0.2" dependency_validator: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 69088be8..6e542d95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.3.11+46 +version: 2.3.12+47 environment: sdk: ">=3.0.0 <4.0.0" @@ -93,6 +93,7 @@ dependencies: go_router: ^10.0.0 fl_chart: ^0.63.0 palette_generator: ^0.3.3+2 + defer_pointer: ^0.0.2 dependency_overrides: intl: ^0.18.1 From 653344c9eed50c38224aac1e7987b10222053eb7 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Thu, 28 Sep 2023 17:14:27 +0200 Subject: [PATCH 09/12] Feat: Update scanner persistence, more migrations and bugfixes --- .../dio_http_error_interceptor.dart | 2 - lib/core/model/info_message_exception.dart | 12 ++ lib/core/navigation/push_routes.dart | 1 + lib/core/security/session_manager.dart | 2 + .../service/connectivity_status_service.dart | 42 ++++- lib/core/service/file_service.dart | 30 +--- .../error_code_localization_mapper.dart | 1 + .../view/pages/document_details_page.dart | 54 ++++-- .../view/widgets/document_share_button.dart | 33 ++-- .../cubit/document_scanner_cubit.dart | 66 +++++--- .../cubit/document_scanner_state.dart | 30 ++++ .../document_scan/view/scanner_page.dart | 158 +++++++++--------- .../cubit/document_search_cubit.dart | 6 + .../cubit/document_upload_cubit.dart | 6 +- .../documents/cubit/documents_cubit.dart | 4 + .../documents/view/pages/documents_page.dart | 23 ++- .../view/widgets/document_preview.dart | 23 +-- .../saved_views/saved_views_widget.dart | 35 ++-- .../widgets/search/document_filter_panel.dart | 1 + .../view/widgets/sort_documents_button.dart | 101 ++++++----- lib/features/home/view/home_shell_widget.dart | 5 +- lib/features/inbox/cubit/inbox_cubit.dart | 5 +- lib/features/inbox/view/pages/inbox_page.dart | 91 +++++----- .../inbox/view/widgets/inbox_item.dart | 7 +- .../labels/view/pages/labels_page.dart | 48 +++--- .../labels/view/widgets/label_tab_view.dart | 2 +- lib/features/landing/view/landing_page.dart | 2 +- .../cubit/linked_documents_cubit.dart | 5 +- .../login/cubit/authentication_cubit.dart | 66 +++++--- .../login/cubit/authentication_state.dart | 45 +++-- lib/features/login/view/add_account_page.dart | 3 + .../login_pages/server_login_page.dart | 24 ++- .../cubit/document_paging_bloc_mixin.dart | 33 +++- .../cubit/saved_view_details_cubit.dart | 7 +- .../cubit/saved_view_preview_cubit.dart | 21 ++- .../cubit/saved_view_preview_state.dart | 34 +++- .../view/saved_view_preview.dart | 69 ++++---- .../cubit/similar_documents_cubit.dart | 7 +- .../connectivity_aware_action_wrapper.dart | 63 +++++++ lib/helpers/message_helpers.dart | 13 ++ lib/l10n/intl_ca.arb | 4 + lib/l10n/intl_cs.arb | 4 + lib/l10n/intl_de.arb | 4 + lib/l10n/intl_en.arb | 4 + lib/l10n/intl_es.arb | 4 + lib/l10n/intl_fr.arb | 4 + lib/l10n/intl_pl.arb | 4 + lib/l10n/intl_ru.arb | 4 + lib/l10n/intl_tr.arb | 4 + lib/main.dart | 48 ++++-- lib/routes/typed/branches/labels_route.dart | 1 + packages/mock_server/lib/mock_server.dart | 37 ++-- ...rator.dart => response_delay_factory.dart} | 14 +- .../lib/src/models/paged_search_result.dart | 4 +- .../src/models/paperless_api_exception.dart | 1 + 55 files changed, 883 insertions(+), 438 deletions(-) create mode 100644 lib/core/model/info_message_exception.dart create mode 100644 lib/features/document_scan/cubit/document_scanner_state.dart create mode 100644 lib/helpers/connectivity_aware_action_wrapper.dart rename packages/mock_server/lib/{response_delay_generator.dart => response_delay_factory.dart} (60%) diff --git a/lib/core/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 2d6a0ba8..6c8fb1bd 100644 --- a/lib/core/interceptor/dio_http_error_interceptor.dart +++ b/lib/core/interceptor/dio_http_error_interceptor.dart @@ -39,8 +39,6 @@ class DioHttpErrorInterceptor extends Interceptor { ), ); } - } else { - return handler.next(err); } } } diff --git a/lib/core/model/info_message_exception.dart b/lib/core/model/info_message_exception.dart new file mode 100644 index 00000000..817954ed --- /dev/null +++ b/lib/core/model/info_message_exception.dart @@ -0,0 +1,12 @@ +import 'package:paperless_api/paperless_api.dart'; + +class InfoMessageException implements Exception { + final ErrorCode code; + final String? message; + final StackTrace? stackTrace; + InfoMessageException({ + required this.code, + this.message, + this.stackTrace, + }); +} diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart index 3bd90c0e..0dfad7cf 100644 --- a/lib/core/navigation/push_routes.dart +++ b/lib/core/navigation/push_routes.dart @@ -43,6 +43,7 @@ Future pushSavedViewDetailsRoute( context.read(), context.read(), LocalUserAppState.current, + context.read(), savedView: savedView, ), child: SavedViewDetailsPage( diff --git a/lib/core/security/session_manager.dart b/lib/core/security/session_manager.dart index 8281f2a3..8f2aba36 100644 --- a/lib/core/security/session_manager.dart +++ b/lib/core/security/session_manager.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; @@ -37,6 +38,7 @@ class SessionManager extends ValueNotifier { ...interceptors, DioUnauthorizedInterceptor(), DioHttpErrorInterceptor(), + DioOfflineInterceptor(), PrettyDioLogger( compact: true, responseBody: false, diff --git a/lib/core/service/connectivity_status_service.dart b/lib/core/service/connectivity_status_service.dart index 0908e485..6ce404b0 100644 --- a/lib/core/service/connectivity_status_service.dart +++ b/lib/core/service/connectivity_status_service.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/core/interceptor/server_reachability_error_inte import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; +import 'package:rxdart/subjects.dart'; abstract class ConnectivityStatusService { Future isConnectedToInternet(); @@ -20,14 +21,19 @@ abstract class ConnectivityStatusService { class ConnectivityStatusServiceImpl implements ConnectivityStatusService { final Connectivity _connectivity; + final BehaviorSubject _connectivityState$ = BehaviorSubject(); - ConnectivityStatusServiceImpl(this._connectivity); + ConnectivityStatusServiceImpl(this._connectivity) { + _connectivityState$.addStream( + _connectivity.onConnectivityChanged + .map(_hasActiveInternetConnection) + .asBroadcastStream(), + ); + } @override Stream connectivityChanges() { - return _connectivity.onConnectivityChanged - .map(_hasActiveInternetConnection) - .asBroadcastStream(); + return _connectivityState$.asBroadcastStream(); } @override @@ -98,3 +104,31 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { return ReachabilityStatus.notReachable; } } + +class ConnectivityStatusServiceMock implements ConnectivityStatusService { + final bool isConnected; + + ConnectivityStatusServiceMock(this.isConnected); + @override + Stream connectivityChanges() { + return Stream.value(isConnected); + } + + @override + Future isConnectedToInternet() async { + return isConnected; + } + + @override + Future isPaperlessServerReachable(String serverAddress, + [ClientCertificate? clientCertificate]) async { + return isConnected + ? ReachabilityStatus.reachable + : ReachabilityStatus.notReachable; + } + + @override + Future isServerReachable(String serverAddress) async { + return isConnected; + } +} diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 881452d5..00145a8a 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -25,7 +25,7 @@ class FileService { case PaperlessDirectoryType.temporary: return temporaryDirectory; case PaperlessDirectoryType.scans: - return scanDirectory; + return temporaryScansDirectory; case PaperlessDirectoryType.download: return downloadsDirectory; } @@ -52,8 +52,7 @@ class FileService { } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/documents'); - dir.createSync(); - return dir; + return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); } @@ -72,33 +71,22 @@ class FileService { } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/downloads'); - dir.createSync(); - return dir; + return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); } } - static Future get scanDirectory async { - if (Platform.isAndroid) { - final scanDir = await getExternalStorageDirectories( - type: StorageDirectory.dcim, - ); - return scanDir!.first; - } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/scans'); - dir.createSync(); - return dir; - } else { - throw UnsupportedError("Platform not supported."); - } + static Future get temporaryScansDirectory async { + final tempDir = await temporaryDirectory; + final scansDir = Directory('${tempDir.path}/scans'); + return scansDir.create(recursive: true); } static Future clearUserData() async { - final scanDir = await scanDirectory; + final scanDir = await temporaryScansDirectory; final tempDir = await temporaryDirectory; - await scanDir?.delete(recursive: true); + await scanDir.delete(recursive: true); await tempDir.delete(recursive: true); } diff --git a/lib/core/translation/error_code_localization_mapper.dart b/lib/core/translation/error_code_localization_mapper.dart index c720d9ea..ac1204e1 100644 --- a/lib/core/translation/error_code_localization_mapper.dart +++ b/lib/core/translation/error_code_localization_mapper.dart @@ -75,5 +75,6 @@ String translateError(BuildContext context, ErrorCode code) { ErrorCode.loadTasksError => S.of(context)!.couldNotLoadTasks, ErrorCode.userNotFound => S.of(context)!.userNotFound, ErrorCode.updateSavedViewError => S.of(context)!.couldNotUpdateSavedView, + ErrorCode.userAlreadyExists => S.of(context)!.userAlreadyExists, }; } 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 aece9c15..b3a1d190 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,3 +1,4 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -20,6 +21,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/document_previe import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -199,6 +201,7 @@ class _DocumentDetailsPageState extends State { context.read(), context.read(), context.read(), + context.read(), documentId: state.document.id, ), child: Padding( @@ -322,28 +325,45 @@ class _DocumentDetailsPageState extends State { return BottomAppBar( child: BlocBuilder( builder: (context, connectivityState) { - final isConnected = connectivityState.isConnected; final currentUser = context.watch(); - final canDelete = - isConnected && currentUser.paperlessUser.canDeleteDocuments; return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - IconButton( - tooltip: S.of(context)!.deleteDocumentTooltip, - icon: const Icon(Icons.delete), - onPressed: - canDelete ? () => _onDelete(state.document) : null, - ).paddedSymmetrically(horizontal: 4), - DocumentDownloadButton( - document: state.document, - enabled: isConnected, + ConnectivityAwareActionWrapper( + disabled: !currentUser.paperlessUser.canDeleteDocuments, + offlineBuilder: (context, child) { + return const IconButton( + icon: Icon(Icons.delete), + onPressed: null, + ).paddedSymmetrically(horizontal: 4); + }, + child: IconButton( + tooltip: S.of(context)!.deleteDocumentTooltip, + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(state.document), + ).paddedSymmetrically(horizontal: 4), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => + const DocumentDownloadButton( + document: null, + enabled: false, + ), + child: DocumentDownloadButton( + document: state.document, + ), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.open_in_new), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.openInSystemViewer, + icon: const Icon(Icons.open_in_new), + onPressed: _onOpenFileInSystemViewer, + ).paddedOnly(right: 4.0), ), - IconButton( - tooltip: S.of(context)!.openInSystemViewer, - icon: const Icon(Icons.open_in_new), - onPressed: isConnected ? _onOpenFileInSystemViewer : null, - ).paddedOnly(right: 4.0), DocumentShareButton(document: state.document), IconButton( tooltip: S.of(context)!.print, diff --git a/lib/features/document_details/view/widgets/document_share_button.dart b/lib/features/document_details/view/widgets/document_share_button.dart index 257ac6b6..aaeb0c0f 100644 --- a/lib/features/document_details/view/widgets/document_share_button.dart +++ b/lib/features/document_details/view/widgets/document_share_button.dart @@ -11,6 +11,7 @@ import 'package:paperless_mobile/features/document_details/cubit/document_detail import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/settings/model/file_download_type.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -34,19 +35,25 @@ class _DocumentShareButtonState extends State { @override Widget build(BuildContext context) { - return IconButton( - tooltip: S.of(context)!.shareTooltip, - icon: _isDownloadPending - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(), - ) - : const Icon(Icons.share), - onPressed: widget.document != null && widget.enabled - ? () => _onShare(widget.document!) - : null, - ).paddedOnly(right: 4); + return ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.share), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.shareTooltip, + icon: _isDownloadPending + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.share), + onPressed: widget.document != null && widget.enabled + ? () => _onShare(widget.document!) + : null, + ).paddedOnly(right: 4), + ); } Future _onShare(DocumentModel document) async { diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 3e0b9a54..de54d25d 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -1,43 +1,71 @@ -import 'dart:developer'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:rxdart/rxdart.dart'; -class DocumentScannerCubit extends Cubit> { +part 'document_scanner_state.dart'; + +class DocumentScannerCubit extends Cubit { final LocalNotificationService _notificationService; - DocumentScannerCubit(this._notificationService) : super(const []); + DocumentScannerCubit(this._notificationService) + : super(const InitialDocumentScannerState()); + + Future initialize() async { + debugPrint("Restoring scans..."); + emit(const RestoringDocumentScannerState()); + final tempDir = await FileService.temporaryScansDirectory; + final allFiles = tempDir.list().whereType(); + final scans = + await allFiles.where((event) => event.path.endsWith(".jpeg")).toList(); + debugPrint("Restored ${scans.length} scans."); + emit( + scans.isEmpty + ? const InitialDocumentScannerState() + : LoadedDocumentScannerState(scans: scans), + ); + } - void addScan(File file) => emit([...state, file]); + void addScan(File file) async { + emit(LoadedDocumentScannerState( + scans: [...state.scans, file], + )); + } - void removeScan(int fileIndex) { + Future removeScan(File file) async { try { - state[fileIndex].deleteSync(); - final scans = [...state]; - scans.removeAt(fileIndex); - emit(scans); - } catch (_) { - throw const PaperlessApiException(ErrorCode.scanRemoveFailed); + await file.delete(); + } catch (error, stackTrace) { + throw InfoMessageException( + code: ErrorCode.scanRemoveFailed, + message: error.toString(), + stackTrace: stackTrace, + ); } + final scans = state.scans..remove(file); + emit( + scans.isEmpty + ? const InitialDocumentScannerState() + : LoadedDocumentScannerState(scans: scans), + ); } - void reset() { + Future reset() async { try { - for (final doc in state) { - doc.deleteSync(); - if (kDebugMode) { - log('[ScannerCubit]: Removed ${doc.path}'); - } - } + Future.wait([ + for (final file in state.scans) file.delete(), + ]); imageCache.clear(); - emit([]); } catch (_) { throw const PaperlessApiException(ErrorCode.scanRemoveFailed); + } finally { + emit(const InitialDocumentScannerState()); } } diff --git a/lib/features/document_scan/cubit/document_scanner_state.dart b/lib/features/document_scan/cubit/document_scanner_state.dart new file mode 100644 index 00000000..70f7b339 --- /dev/null +++ b/lib/features/document_scan/cubit/document_scanner_state.dart @@ -0,0 +1,30 @@ +part of 'document_scanner_cubit.dart'; + +sealed class DocumentScannerState { + final List scans; + + const DocumentScannerState({ + this.scans = const [], + }); +} + +class InitialDocumentScannerState extends DocumentScannerState { + const InitialDocumentScannerState(); +} + +class RestoringDocumentScannerState extends DocumentScannerState { + const RestoringDocumentScannerState({super.scans}); +} + +class LoadedDocumentScannerState extends DocumentScannerState { + const LoadedDocumentScannerState({super.scans}); +} + +class ErrorDocumentScannerState extends DocumentScannerState { + final String message; + + const ErrorDocumentScannerState({ + required this.message, + super.scans, + }); +} diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 760e7b5c..4ea62ca9 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -10,7 +10,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/global/constants.dart'; @@ -25,6 +24,7 @@ import 'package:paperless_mobile/features/document_upload/view/document_upload_p import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; @@ -52,66 +52,54 @@ class _ScannerPageState extends State @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectedState) { - return BlocBuilder>( - builder: (context, state) { - return SafeArea( - top: true, - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - heroTag: "fab_document_edit", - onPressed: () => _openDocumentScanner(context), - child: const Icon(Icons.add_a_photo_outlined), - ), - body: NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: SliverSearchBar( - titleText: S.of(context)!.scanner, - ), - ), - SliverOverlapAbsorber( - handle: actionsHandle, - sliver: SliverPinnedHeader( - child: _buildActions(connectedState.isConnected), - ), - ), - ], - body: BlocBuilder>( - builder: (context, state) { - if (state.isEmpty) { - return SizedBox.expand( - child: Center( - child: _buildEmptyState( - connectedState.isConnected, - state, - ), - ), - ); - } else { - return _buildImageGrid(state); - } - }, - ), - ), + return SafeArea( + top: true, + child: Scaffold( + drawer: const AppDrawer(), + floatingActionButton: FloatingActionButton( + heroTag: "fab_document_edit", + onPressed: () => _openDocumentScanner(context), + child: const Icon(Icons.add_a_photo_outlined), + ), + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: SliverSearchBar( + titleText: S.of(context)!.scanner, ), - ); - }, - ); - }, + ), + SliverOverlapAbsorber( + handle: actionsHandle, + sliver: SliverPinnedHeader( + child: _buildActions(), + ), + ), + ], + body: BlocBuilder( + builder: (context, state) { + return switch (state) { + InitialDocumentScannerState() => _buildEmptyState(), + RestoringDocumentScannerState() => Center( + child: Text("Restoring..."), + ), + LoadedDocumentScannerState() => _buildImageGrid(state.scans), + ErrorDocumentScannerState() => Placeholder(), + }; + }, + ), + ), + ), ); } - Widget _buildActions(bool isConnected) { + Widget _buildActions() { return ColoredBox( color: Theme.of(context).colorScheme.background, child: SizedBox( height: kTextTabBarHeight, - child: BlocBuilder>( + child: BlocBuilder( builder: (context, state) { return RawScrollbar( padding: EdgeInsets.fromLTRB(16, 0, 16, 4), @@ -130,12 +118,12 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isNotEmpty + onPressed: state.scans.isNotEmpty ? () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => DocumentView( documentBytes: _assembleFileBytes( - state, + state.scans, forcePdf: true, ).then((file) => file.bytes), ), @@ -150,19 +138,32 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isEmpty ? null : () => _reset(context), + onPressed: + state.scans.isEmpty ? null : () => _reset(context), icon: const Icon(Icons.delete_sweep_outlined), ), SizedBox(width: 8), - TextButton.icon( - label: Text(S.of(context)!.upload), - style: TextButton.styleFrom( - padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) { + return TextButton.icon( + label: Text(S.of(context)!.upload), + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ), + onPressed: null, + icon: const Icon(Icons.upload_outlined), + ); + }, + disabled: state.scans.isEmpty, + child: TextButton.icon( + label: Text(S.of(context)!.upload), + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), + ), + onPressed: () => + _onPrepareDocumentUpload(context, state.scans), + icon: const Icon(Icons.upload_outlined), ), - onPressed: state.isEmpty || !isConnected - ? null - : () => _onPrepareDocumentUpload(context), - icon: const Icon(Icons.upload_outlined), ), SizedBox(width: 8), TextButton.icon( @@ -170,7 +171,7 @@ class _ScannerPageState extends State style: TextButton.styleFrom( padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), ), - onPressed: state.isEmpty ? null : _onSaveToFile, + onPressed: state.scans.isEmpty ? null : _onSaveToFile, icon: const Icon(Icons.save_alt_outlined), ), SizedBox(width: 12), @@ -192,7 +193,7 @@ class _ScannerPageState extends State final cubit = context.read(); final file = await _assembleFileBytes( forcePdf: true, - context.read().state, + context.read().state.scans, ); try { final globalSettings = @@ -249,9 +250,9 @@ class _ScannerPageState extends State context.read().addScan(file); } - void _onPrepareDocumentUpload(BuildContext context) async { + void _onPrepareDocumentUpload(BuildContext context, List scans) async { final file = await _assembleFileBytes( - context.read().state, + scans, forcePdf: Hive.box(HiveBoxes.globalSettings) .getValue()! .enforceSinglePagePdfUpload, @@ -269,10 +270,7 @@ class _ScannerPageState extends State } } - Widget _buildEmptyState(bool isConnected, List scans) { - if (scans.isNotEmpty) { - return _buildImageGrid(scans); - } + Widget _buildEmptyState() { return Center( child: Padding( padding: const EdgeInsets.all(8.0), @@ -288,9 +286,15 @@ class _ScannerPageState extends State onPressed: () => _openDocumentScanner(context), ), Text(S.of(context)!.or), - TextButton( - child: Text(S.of(context)!.uploadADocumentFromThisDevice), - onPressed: isConnected ? _onUploadFromFilesystem : null, + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => TextButton( + child: Text(S.of(context)!.uploadADocumentFromThisDevice), + onPressed: null, + ), + child: TextButton( + child: Text(S.of(context)!.uploadADocumentFromThisDevice), + onPressed: _onUploadFromFilesystem, + ), ), ], ), @@ -318,7 +322,9 @@ class _ScannerPageState extends State file: scans[index], onDelete: () async { try { - context.read().removeScan(index); + context + .read() + .removeScan(scans[index]); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart index b8ee1255..09e4ffc4 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -4,6 +4,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -119,4 +120,9 @@ class DocumentSearchCubit extends Cubit @override Future onFilterUpdated(DocumentFilter filter) async {} + + @override + // TODO: implement connectivityStatusService + ConnectivityStatusService get connectivityStatusService => + throw UnimplementedError(); } diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index ae275a5d..e1d5bec8 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'document_upload_state.dart'; @@ -13,12 +13,12 @@ class DocumentUploadCubit extends Cubit { final PaperlessDocumentsApi _documentApi; final LabelRepository _labelRepository; - final Connectivity _connectivity; + final ConnectivityStatusService _connectivityStatusService; DocumentUploadCubit( this._labelRepository, this._documentApi, - this._connectivity, + this._connectivityStatusService, ) : super(const DocumentUploadState()) { _labelRepository.addListener( this, diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index 218b8a6a..da1add14 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -20,6 +21,8 @@ class DocumentsCubit extends HydratedCubit final PaperlessDocumentsApi api; final LabelRepository _labelRepository; + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -31,6 +34,7 @@ class DocumentsCubit extends HydratedCubit this.notifier, this._labelRepository, this._userState, + this.connectivityStatusService, ) : super(DocumentsState( filter: _userState.currentDocumentFilter, viewType: _userState.documentsPageViewType, diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 9c1df845..d506060e 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.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'; @@ -333,12 +334,16 @@ class _DocumentsPageState extends State { slivers: [ SliverOverlapInjector(handle: searchBarHandle), SliverOverlapInjector(handle: savedViewsHandle), - BlocBuilder( - buildWhen: (previous, current) => - previous.filter != current.filter, - builder: (context, state) { - return SliverToBoxAdapter( - child: SavedViewsWidget( + SliverToBoxAdapter( + child: BlocBuilder( + buildWhen: (previous, current) => + previous.filter != current.filter, + builder: (context, state) { + final currentUser = context.watch(); + if (!currentUser.paperlessUser.canViewSavedViews) { + return const SizedBox.shrink(); + } + return SavedViewsWidget( controller: _savedViewsExpansionController, onViewSelected: (view) { final cubit = context.read(); @@ -372,9 +377,9 @@ class _DocumentsPageState extends State { } }, filter: state.filter, - ), - ); - }, + ); + }, + ), ), BlocBuilder( builder: (context, state) { diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 576d04d7..f99a1397 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; @@ -28,17 +29,17 @@ class DocumentPreview extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: isClickable - ? () { - DocumentPreviewRoute($extra: document).push(context); - } - : null, - child: HeroMode( - enabled: enableHero, - child: Hero( - tag: "thumb_${document.id}", - child: _buildPreview(context), + return ConnectivityAwareActionWrapper( + child: GestureDetector( + onTap: isClickable + ? () => DocumentPreviewRoute($extra: document).push(context) + : null, + child: HeroMode( + enabled: enableHero, + child: Hero( + tag: "thumb_${document.id}", + child: _buildPreview(context), + ), ), ), ); diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 3bf926f2..0480d17a 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -6,6 +6,7 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; class SavedViewsWidget extends StatefulWidget { @@ -146,15 +147,17 @@ class _SavedViewsWidgetState extends State final isSelected = (widget.filter.selectedView ?? -1) == view.id; - return SavedViewChip( - view: view, - onViewSelected: widget.onViewSelected, - selected: isSelected, - hasChanged: isSelected && - view.toDocumentFilter() != - widget.filter, - onUpdateView: widget.onUpdateView, - onDeleteView: widget.onDeleteView, + return ConnectivityAwareActionWrapper( + child: SavedViewChip( + view: view, + onViewSelected: widget.onViewSelected, + selected: isSelected, + hasChanged: isSelected && + view.toDocumentFilter() != + widget.filter, + onUpdateView: widget.onUpdateView, + onDeleteView: widget.onDeleteView, + ), ); }, separatorBuilder: (context, index) => @@ -178,12 +181,14 @@ class _SavedViewsWidgetState extends State alignment: Alignment.centerRight, child: Tooltip( message: S.of(context)!.createFromCurrentFilter, - child: TextButton.icon( - onPressed: () { - CreateSavedViewRoute(widget.filter).push(context); - }, - icon: const Icon(Icons.add), - label: Text(S.of(context)!.newView), + child: ConnectivityAwareActionWrapper( + child: TextButton.icon( + onPressed: () { + CreateSavedViewRoute(widget.filter).push(context); + }, + icon: const Icon(Icons.add), + label: Text(S.of(context)!.newView), + ), ), ).padded(4), ), diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index 8cdb5afc..191d9222 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -6,6 +6,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; enum DateRangeSelection { before, after } diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index 61b15a6d..ec94f632 100644 --- a/lib/features/documents/view/widgets/sort_documents_button.dart +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -5,6 +5,7 @@ import 'package:paperless_mobile/core/translation/sort_field_localization_mapper import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; class SortDocumentsButton extends StatelessWidget { final bool enabled; @@ -20,55 +21,65 @@ class SortDocumentsButton extends StatelessWidget { if (state.filter.sortField == null) { return const SizedBox.shrink(); } - print(state.filter.sortField); - return TextButton.icon( - icon: Icon(state.filter.sortOrder == SortOrder.ascending - ? Icons.arrow_upward - : Icons.arrow_downward), - label: Text(translateSortField(context, state.filter.sortField)), - onPressed: enabled - ? () { - showModalBottomSheet( - elevation: 2, - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), + final icon = Icon(state.filter.sortOrder == SortOrder.ascending + ? Icons.arrow_upward + : Icons.arrow_downward); + final label = Text(translateSortField(context, state.filter.sortField)); + return ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) { + return TextButton.icon( + icon: icon, + label: label, + onPressed: null, + ); + }, + child: TextButton.icon( + icon: icon, + label: label, + onPressed: enabled + ? () { + showModalBottomSheet( + elevation: 2, + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), ), - ), - builder: (_) => BlocProvider.value( - value: context.read(), - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => LabelCubit(context.read()), + builder: (_) => BlocProvider.value( + value: context.read(), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + ], + child: SortFieldSelectionBottomSheet( + initialSortField: state.filter.sortField, + initialSortOrder: state.filter.sortOrder, + onSubmit: (field, order) { + return context + .read() + .updateCurrentFilter( + (filter) => filter.copyWith( + sortField: field, + sortOrder: order, + ), + ); + }, + correspondents: state.correspondents, + documentTypes: state.documentTypes, + storagePaths: state.storagePaths, + tags: state.tags, ), - ], - child: SortFieldSelectionBottomSheet( - initialSortField: state.filter.sortField, - initialSortOrder: state.filter.sortOrder, - onSubmit: (field, order) { - return context - .read() - .updateCurrentFilter( - (filter) => filter.copyWith( - sortField: field, - sortOrder: order, - ), - ); - }, - correspondents: state.correspondents, - documentTypes: state.documentTypes, - storagePaths: state.storagePaths, - tags: state.tags, ), ), - ), - ); - } - : null, + ); + } + : null, + ), ); }, ); diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 24198787..e73e3028 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -160,11 +160,13 @@ class HomeShellWidget extends StatelessWidget { Hive.box( HiveBoxes.localUserAppState) .get(currentUserId)!, + context.read(), )..initialize(), ), Provider( create: (context) => - DocumentScannerCubit(context.read()), + DocumentScannerCubit(context.read()) + ..initialize(), ), Provider( create: (context) { @@ -173,6 +175,7 @@ class HomeShellWidget extends StatelessWidget { context.read(), context.read(), context.read(), + context.read(), ); if (currentLocalUser .paperlessUser.canViewDocuments && diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 3356fd53..5bd59a29 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; @@ -18,7 +19,8 @@ class InboxCubit extends HydratedCubit final LabelRepository _labelRepository; final PaperlessDocumentsApi _documentsApi; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -32,6 +34,7 @@ class InboxCubit extends HydratedCubit this._statsApi, this._labelRepository, this.notifier, + this.connectivityStatusService, ) : super(InboxState( labels: _labelRepository.state, )) { diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index ef65bfa7..02784f3e 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -5,6 +5,7 @@ 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/service/connectivity_status_service.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'; @@ -17,6 +18,7 @@ import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget. 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/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; class InboxPage extends StatefulWidget { @@ -74,45 +76,50 @@ class _InboxPageState extends State 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( - extendedPadding: _showExtendedFab - ? null - : const EdgeInsets.symmetric(horizontal: 16), - heroTag: "inbox_page_fab", - label: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: child, - ), - ); - }, - child: _showExtendedFab - ? Row( - children: [ - const Icon(Icons.done_all), - Text(S.of(context)!.allSeen), - ], - ) - : const Icon(Icons.done_all), - ), - onPressed: state.hasLoaded && state.documents.isNotEmpty - ? () => _onMarkAllAsSeen( - state.documents, - state.inboxTags, - ) - : null, - ); - }, + floatingActionButton: ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const SizedBox.shrink(), + child: BlocBuilder( + builder: (context, state) { + if (!state.hasLoaded || + state.documents.isEmpty || + !canEditDocument) { + return const SizedBox.shrink(); + } + return FloatingActionButton.extended( + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric(horizontal: 16), + heroTag: "inbox_page_fab", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon(Icons.done_all), + Text(S.of(context)!.allSeen), + ], + ) + : const Icon(Icons.done_all), + ), + onPressed: state.hasLoaded && state.documents.isNotEmpty + ? () => _onMarkAllAsSeen( + state.documents, + state.inboxTags, + ) + : null, + ); + }, + ), ), body: SafeArea( top: true, @@ -268,6 +275,12 @@ class _InboxPageState extends State showSnackBar(context, S.of(context)!.missingPermissions); return false; } + final isConnectedToInternet = + await context.read().isConnectedToInternet(); + if (!isConnectedToInternet) { + showSnackBar(context, S.of(context)!.youAreCurrentlyOffline); + return false; + } try { final removedTags = await context.read().removeFromInbox(doc); showSnackBar( diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index 656088f7..70fa0344 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -1,10 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -16,6 +14,7 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.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/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; class InboxItemPlaceholder extends StatelessWidget { @@ -228,7 +227,9 @@ class _InboxItemState extends State { ), LimitedBox( maxHeight: 56, - child: _buildActions(context), + child: ConnectivityAwareActionWrapper( + child: _buildActions(context), + ), ), ], ).paddedOnly(left: 8, top: 8, bottom: 8), diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 896fcea2..213299e3 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -13,6 +13,7 @@ import 'package:paperless_mobile/features/document_search/view/sliver_search_bar import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; class LabelsPage extends StatefulWidget { @@ -66,36 +67,37 @@ class _LabelsPageState extends State .getValue()! .loggedInUserId; final user = box.get(currentUserId)!.paperlessUser; - + final fabLabel = [ + S.of(context)!.addCorrespondent, + S.of(context)!.addDocumentType, + S.of(context)!.addTag, + S.of(context)!.addStoragePath, + ][_currentIndex]; return BlocBuilder( builder: (context, connectedState) { return SafeArea( child: Scaffold( drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton.extended( - heroTag: "inbox_page_fab", - label: Text( - [ - S.of(context)!.addCorrespondent, - S.of(context)!.addDocumentType, - S.of(context)!.addTag, - S.of(context)!.addStoragePath, + floatingActionButton: ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const SizedBox.shrink(), + child: FloatingActionButton.extended( + heroTag: "inbox_page_fab", + label: Text(fabLabel), + icon: Icon(Icons.add), + onPressed: [ + if (user.canViewCorrespondents) + () => CreateLabelRoute(LabelType.correspondent) + .push(context), + if (user.canViewDocumentTypes) + () => CreateLabelRoute(LabelType.documentType) + .push(context), + if (user.canViewTags) + () => CreateLabelRoute(LabelType.tag).push(context), + if (user.canViewStoragePaths) + () => CreateLabelRoute(LabelType.storagePath) + .push(context), ][_currentIndex], ), - icon: Icon(Icons.add), - onPressed: [ - if (user.canViewCorrespondents) - () => CreateLabelRoute(LabelType.correspondent) - .push(context), - if (user.canViewDocumentTypes) - () => CreateLabelRoute(LabelType.documentType) - .push(context), - if (user.canViewTags) - () => CreateLabelRoute(LabelType.tag).push(context), - if (user.canViewStoragePaths) - () => CreateLabelRoute(LabelType.storagePath) - .push(context), - ][_currentIndex], ), body: NestedScrollView( floatHeaderSlivers: true, diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index d99533e4..d02aee6a 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -44,7 +44,7 @@ class LabelTabView extends StatelessWidget { return BlocBuilder( builder: (context, connectivityState) { if (!connectivityState.isConnected) { - return const OfflineWidget(); + return const SliverFillRemaining(child: OfflineWidget()); } final sortedLabels = labels.values.toList()..sort(); if (labels.isEmpty) { diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index da6e340d..e4538533 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -22,6 +22,7 @@ class LandingPage extends StatefulWidget { class _LandingPageState extends State { final _searchBarHandle = SliverOverlapAbsorberHandle(); + @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; @@ -121,7 +122,6 @@ class _LandingPageState extends State { Widget _buildStatisticsCard(BuildContext context) { final currentUser = context.read().paperlessUser; - return ExpansionCard( initiallyExpanded: false, title: Text( diff --git a/lib/features/linked_documents/cubit/linked_documents_cubit.dart b/lib/features/linked_documents/cubit/linked_documents_cubit.dart index 39e3c0df..c7fbeba3 100644 --- a/lib/features/linked_documents/cubit/linked_documents_cubit.dart +++ b/lib/features/linked_documents/cubit/linked_documents_cubit.dart @@ -3,6 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -14,7 +15,8 @@ class LinkedDocumentsCubit extends HydratedCubit with DocumentPagingBlocMixin { @override final PaperlessDocumentsApi api; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -25,6 +27,7 @@ class LinkedDocumentsCubit extends HydratedCubit this.api, this.notifier, this._labelRepository, + this.connectivityStatusService, ) : super(LinkedDocumentsState(filter: filter)) { updateFilter(filter: filter); _labelRepository.addListener( diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 8e8b56d1..cc695f9c 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,9 +1,11 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; @@ -12,25 +14,28 @@ import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart' import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -part 'authentication_cubit.freezed.dart'; part 'authentication_state.dart'; class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessApiFactory _apiFactory; final SessionManager _sessionManager; + final ConnectivityStatusService _connectivityService; AuthenticationCubit( this._localAuthService, this._apiFactory, this._sessionManager, - ) : super(const AuthenticationState.unauthenticated()); + this._connectivityService, + ) : super(const UnauthenticatedState()); Future login({ required LoginFormCredentials credentials, @@ -51,8 +56,6 @@ class AuthenticationCubit extends Cubit { _sessionManager, ); - final apiVersion = await _getApiVersion(_sessionManager.client); - // Mark logged in user as currently active user. final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; @@ -60,7 +63,7 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit( - AuthenticationState.authenticated( + AuthenticatedState( localUserId: localUserId, ), ); @@ -72,11 +75,11 @@ class AuthenticationCubit extends Cubit { /// Switches to another account if it exists. Future switchAccount(String localUserId) async { - emit(const AuthenticationState.switchingAccounts()); + emit(const SwitchingAccountsState()); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; if (globalSettings.loggedInUserId == localUserId) { - emit(AuthenticationState.authenticated(localUserId: localUserId)); + emit(AuthenticatedState(localUserId: localUserId)); return; } final userAccountBox = @@ -125,7 +128,7 @@ class AuthenticationCubit extends Cubit { apiVersion, ); - emit(AuthenticationState.authenticated( + emit(AuthenticatedState( localUserId: localUserId, )); }); @@ -182,7 +185,7 @@ class AuthenticationCubit extends Cubit { "There is nothing to restore.", ); // If there is nothing to restore, we can quit here. - emit(const AuthenticationState.unauthenticated()); + emit(const UnauthenticatedState()); return; } final localUserAccountBox = @@ -203,7 +206,7 @@ class AuthenticationCubit extends Cubit { final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - emit(const AuthenticationState.requriresLocalAuthentication()); + emit(const RequiresLocalAuthenticationState()); _debugPrintMessage( "restoreSessionState", "User could not be authenticated.", @@ -239,14 +242,17 @@ class AuthenticationCubit extends Cubit { "User should be authenticated but no authentication information was found.", ); } + _debugPrintMessage( "restoreSessionState", "Authentication credentials successfully retrieved.", ); + _debugPrintMessage( "restoreSessionState", "Updating current session state...", ); + _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, @@ -256,18 +262,32 @@ class AuthenticationCubit extends Cubit { "restoreSessionState", "Current session state successfully updated.", ); + final hasInternetConnection = + await _connectivityService.isConnectedToInternet(); + if (hasInternetConnection) { + _debugPrintMessage( + "restoreSessionMState", + "Updating server user...", + ); + final apiVersion = await _getApiVersion(_sessionManager.client); + await _updateRemoteUser( + _sessionManager, + localUserAccount, + apiVersion, + ); + _debugPrintMessage( + "restoreSessionMState", + "Successfully updated server user.", + ); + } else { + _debugPrintMessage( + "restoreSessionMState", + "Skipping update of server user (no internet connection).", + ); + } + + emit(AuthenticatedState(localUserId: localUserId)); - final apiVersion = await _getApiVersion(_sessionManager.client); - await _updateRemoteUser( - _sessionManager, - localUserAccount, - apiVersion, - ); - emit( - AuthenticationState.authenticated( - localUserId: localUserId, - ), - ); _debugPrintMessage( "restoreSessionState", "Session was successfully restored.", @@ -285,7 +305,7 @@ class AuthenticationCubit extends Cubit { globalSettings.loggedInUserId = null; await globalSettings.save(); - emit(const AuthenticationState.unauthenticated()); + emit(const UnauthenticatedState()); _debugPrintMessage( "logout", "User successfully logged out.", @@ -353,7 +373,7 @@ class AuthenticationCubit extends Cubit { "_addUser", "An error occurred! The user $localUserId already exists.", ); - throw Exception("User already exists!"); + throw InfoMessageException(code: ErrorCode.userAlreadyExists); } final apiVersion = await _getApiVersion(sessionManager.client); _debugPrintMessage( diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index 12530aa9..db4455cb 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -1,19 +1,32 @@ part of 'authentication_cubit.dart'; -@freezed -class AuthenticationState with _$AuthenticationState { - const AuthenticationState._(); - - const factory AuthenticationState.unauthenticated() = _Unauthenticated; - const factory AuthenticationState.requriresLocalAuthentication() = - _RequiresLocalAuthentication; - const factory AuthenticationState.authenticated({ - required String localUserId, - }) = _Authenticated; - const factory AuthenticationState.switchingAccounts() = _SwitchingAccounts; - - bool get isAuthenticated => maybeWhen( - authenticated: (_) => true, - orElse: () => false, - ); +sealed class AuthenticationState { + const AuthenticationState(); + + bool get isAuthenticated => + switch (this) { AuthenticatedState() => true, _ => false }; +} + +class UnauthenticatedState extends AuthenticationState { + const UnauthenticatedState(); +} + +class RequiresLocalAuthenticationState extends AuthenticationState { + const RequiresLocalAuthenticationState(); +} + +class AuthenticatedState extends AuthenticationState { + final String localUserId; + + const AuthenticatedState({ + required this.localUserId, + }); +} + +class SwitchingAccountsState extends AuthenticationState { + const SwitchingAccountsState(); +} + +class AuthenticationErrorState extends AuthenticationState { + const AuthenticationErrorState(); } diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index 72ed0293..7e1bdc60 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -8,6 +8,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.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/model/info_message_exception.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; @@ -153,6 +154,8 @@ class _AddAccountPageState extends State { showErrorMessage(context, error); } on ServerMessageException catch (error) { showLocalizedError(context, error.message); + } on InfoMessageException catch (error) { + showInfoMessage(context, error); } catch (error) { showGenericError(context, error); } diff --git a/lib/features/login/view/widgets/login_pages/server_login_page.dart b/lib/features/login/view/widgets/login_pages/server_login_page.dart index df64d69e..1951aa3b 100644 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ b/lib/features/login/view/widgets/login_pages/server_login_page.dart @@ -52,10 +52,11 @@ class _ServerLoginPageState extends State { Text( S.of(context)!.loginRequiredPermissionsHint, style: Theme.of(context).textTheme.bodySmall?.apply( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6)), + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + ), ).padded(16), ], ), @@ -64,11 +65,16 @@ class _ServerLoginPageState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton( - onPressed: () async { - setState(() => _isLoginLoading = true); - await widget.onSubmit(); - setState(() => _isLoginLoading = false); - }, + onPressed: !_isLoginLoading + ? () async { + setState(() => _isLoginLoading = true); + try { + await widget.onSubmit(); + } finally { + setState(() => _isLoginLoading = false); + } + } + : null, child: Text(S.of(context)!.signIn), ) ], diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 33b43b50..1c683136 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:rxdart/streams.dart'; import 'paged_documents_state.dart'; @@ -11,13 +13,16 @@ import 'paged_documents_state.dart'; /// mixin DocumentPagingBlocMixin on BlocBase { + ConnectivityStatusService get connectivityStatusService; PaperlessDocumentsApi get api; DocumentChangedNotifier get notifier; Future onFilterUpdated(DocumentFilter filter); Future loadMore() async { - if (state.isLastPageLoaded) { + final hasConnection = + await connectivityStatusService.isConnectedToInternet(); + if (state.isLastPageLoaded || !hasConnection) { return; } emit(state.copyWithPaged(isLoading: true)); @@ -47,6 +52,32 @@ mixin DocumentPagingBlocMixin Future updateFilter({ final DocumentFilter filter = const DocumentFilter(), }) async { + final hasConnection = + await connectivityStatusService.isConnectedToInternet(); + if (!hasConnection) { + // Just filter currently loaded documents + final filteredDocuments = state.value + .expand((page) => page.results) + .where((doc) => filter.matches(doc)) + .toList(); + emit(state.copyWithPaged(isLoading: true)); + + emit( + state.copyWithPaged( + filter: filter, + value: [ + PagedSearchResult( + results: filteredDocuments, + count: filteredDocuments.length, + next: null, + previous: null, + ) + ], + hasLoaded: true, + ), + ); + return; + } try { emit(state.copyWithPaged(isLoading: true)); final result = await api.findAll(filter.copyWith(page: 1)); diff --git a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart index e965211c..33af9fa9 100644 --- a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart @@ -3,6 +3,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; @@ -15,7 +16,8 @@ class SavedViewDetailsCubit extends Cubit final PaperlessDocumentsApi api; final LabelRepository _labelRepository; - + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -27,7 +29,8 @@ class SavedViewDetailsCubit extends Cubit this.api, this.notifier, this._labelRepository, - this._userState, { + this._userState, + this.connectivityStatusService, { required this.savedView, int initialCount = 25, }) : super( diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index 19658f79..cffd03d3 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'saved_view_preview_state.dart'; part 'saved_view_preview_cubit.freezed.dart'; @@ -8,11 +9,21 @@ part 'saved_view_preview_cubit.freezed.dart'; class SavedViewPreviewCubit extends Cubit { final PaperlessDocumentsApi _api; final SavedView view; - SavedViewPreviewCubit(this._api, this.view) - : super(const SavedViewPreviewState.initial()); + final ConnectivityStatusService _connectivityStatusService; + SavedViewPreviewCubit( + this._api, + this._connectivityStatusService, { + required this.view, + }) : super(const InitialSavedViewPreviewState()); Future initialize() async { - emit(const SavedViewPreviewState.loading()); + final isConnected = + await _connectivityStatusService.isConnectedToInternet(); + if (!isConnected) { + emit(const OfflineSavedViewPreviewState()); + return; + } + emit(const LoadingSavedViewPreviewState()); try { final documents = await _api.findAll( view.toDocumentFilter().copyWith( @@ -20,9 +31,9 @@ class SavedViewPreviewCubit extends Cubit { pageSize: 5, ), ); - emit(SavedViewPreviewState.loaded(documents: documents.results)); + emit(LoadedSavedViewPreviewState(documents: documents.results)); } catch (e) { - emit(const SavedViewPreviewState.error()); + emit(const ErrorSavedViewPreviewState()); } } } diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_state.dart b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart index 49e49959..a0de1131 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_state.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart @@ -1,11 +1,29 @@ part of 'saved_view_preview_cubit.dart'; -@freezed -class SavedViewPreviewState with _$SavedViewPreviewState { - const factory SavedViewPreviewState.initial() = _Initial; - const factory SavedViewPreviewState.loading() = _Loading; - const factory SavedViewPreviewState.loaded({ - required List documents, - }) = _Loaded; - const factory SavedViewPreviewState.error() = _Error; +sealed class SavedViewPreviewState { + const SavedViewPreviewState(); +} + +class InitialSavedViewPreviewState extends SavedViewPreviewState { + const InitialSavedViewPreviewState(); +} + +class LoadingSavedViewPreviewState extends SavedViewPreviewState { + const LoadingSavedViewPreviewState(); +} + +class LoadedSavedViewPreviewState extends SavedViewPreviewState { + final List documents; + + const LoadedSavedViewPreviewState({ + required this.documents, + }); +} + +class ErrorSavedViewPreviewState extends SavedViewPreviewState { + const ErrorSavedViewPreviewState(); +} + +class OfflineSavedViewPreviewState extends SavedViewPreviewState { + const OfflineSavedViewPreviewState(); } diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 38a1488c..2ebb4598 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -22,8 +22,11 @@ class SavedViewPreview extends StatelessWidget { @override Widget build(BuildContext context) { return Provider( - create: (context) => - SavedViewPreviewCubit(context.read(), savedView)..initialize(), + create: (context) => SavedViewPreviewCubit( + context.read(), + context.read(), + view: savedView, + )..initialize(), builder: (context, child) { return ExpansionCard( initiallyExpanded: expanded, @@ -33,34 +36,40 @@ class SavedViewPreview extends StatelessWidget { children: [ BlocBuilder( builder: (context, state) { - return state.maybeWhen( - loaded: (documents) { - if (documents.isEmpty) { - return Text(S.of(context)!.noDocumentsFound).padded(); - } else { - return Column( - children: [ - for (final document in documents) - DocumentListItem( - document: document, - isLabelClickable: false, - isSelected: false, - isSelectionActive: false, - onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); - }, - onSelected: null, - ), - ], - ); - } - }, - error: () => Text(S.of(context)!.couldNotLoadSavedViews), - orElse: () => const Center( - child: CircularProgressIndicator(), - ).paddedOnly(top: 8, bottom: 24), - ); + return switch (state) { + LoadedSavedViewPreviewState(documents: var documents) => + Builder( + builder: (context) { + if (documents.isEmpty) { + return Text(S.of(context)!.noDocumentsFound) + .padded(); + } else { + return Column( + children: [ + for (final document in documents) + DocumentListItem( + document: document, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + onTap: (document) { + DocumentDetailsRoute($extra: document) + .push(context); + }, + onSelected: null, + ), + ], + ); + } + }, + ), + ErrorSavedViewPreviewState() => + Text(S.of(context)!.couldNotLoadSavedViews).padded(16), + OfflineSavedViewPreviewState() => + Text(S.of(context)!.youAreCurrentlyOffline).padded(16), + _ => const CircularProgressIndicator() + .paddedOnly(top: 8, bottom: 24), + }; }, ), Row( diff --git a/lib/features/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart index 6e24b98a..4ec4653f 100644 --- a/lib/features/similar_documents/cubit/similar_documents_cubit.dart +++ b/lib/features/similar_documents/cubit/similar_documents_cubit.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; @@ -10,7 +11,8 @@ part 'similar_documents_state.dart'; class SimilarDocumentsCubit extends Cubit with DocumentPagingBlocMixin { final int documentId; - + @override + final ConnectivityStatusService connectivityStatusService; @override final PaperlessDocumentsApi api; @@ -22,7 +24,8 @@ class SimilarDocumentsCubit extends Cubit SimilarDocumentsCubit( this.api, this.notifier, - this._labelRepository, { + this._labelRepository, + this.connectivityStatusService, { required this.documentId, }) : super(const SimilarDocumentsState(filter: DocumentFilter())) { notifier.addListener( diff --git a/lib/helpers/connectivity_aware_action_wrapper.dart b/lib/helpers/connectivity_aware_action_wrapper.dart new file mode 100644 index 00000000..f69e922c --- /dev/null +++ b/lib/helpers/connectivity_aware_action_wrapper.dart @@ -0,0 +1,63 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +typedef OfflineBuilder = Widget Function(BuildContext context, Widget? child); + +class ConnectivityAwareActionWrapper extends StatelessWidget { + final OfflineBuilder offlineBuilder; + final Widget child; + final bool disabled; + + static Widget disabledBuilder(BuildContext context, Widget? child) { + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: child, + ); + } + + /// + /// Wrapper widget which is used to disable an actionable [child] + /// (like buttons, chips etc.) which require a connection to the internet. + /// + /// + const ConnectivityAwareActionWrapper({ + super.key, + this.offlineBuilder = ConnectivityAwareActionWrapper.disabledBuilder, + required this.child, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: context.read().connectivityChanges(), + builder: (context, snapshot) { + final disableButton = + !snapshot.hasData || snapshot.data == false || disabled; + if (disableButton) { + return GestureDetector( + onTap: () { + HapticFeedback.heavyImpact(); + showSnackBar(context, S.of(context)!.youAreCurrentlyOffline); + }, + child: AbsorbPointer( + child: offlineBuilder(context, child), + ), + ); + } + return child; + }, + ); + } +} diff --git a/lib/helpers/message_helpers.dart b/lib/helpers/message_helpers.dart index b3020fda..39f532bc 100644 --- a/lib/helpers/message_helpers.dart +++ b/lib/helpers/message_helpers.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; class SnackBarActionConfig { @@ -108,3 +109,15 @@ void showErrorMessage( time: DateTime.now(), ); } + +void showInfoMessage( + BuildContext context, + InfoMessageException error, [ + StackTrace? stackTrace, +]) { + showSnackBar( + context, + translateError(context, error.code), + details: error.message, + ); +} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index e84be1e2..b631de0c 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 265d2db9..82f70c3c 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 730b4b91..8a317111 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -969,5 +969,9 @@ "showAll": "Alle anzeigen", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "Dieser Nutzer existiert bereits.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3acf6b9..1e008546 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index faf5a53b..6096a3fd 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index ad032a2d..308916e4 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index d024a271..cf000d60 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index edd90c20..8d82ccfc 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index dd1b8748..f7795c7a 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -969,5 +969,9 @@ "showAll": "Show all", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "userAlreadyExists": "This user already exists.", + "@userAlreadyExists": { + "description": "Error message shown when the user tries to add an already existing account." } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a39e7daf..416bb005 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -29,16 +28,17 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -103,17 +103,19 @@ void main() async { await findSystemLocale(); packageInfo = await PackageInfo.fromPlatform(); + if (Platform.isAndroid) { androidInfo = await DeviceInfoPlugin().androidInfo; } if (Platform.isIOS) { iosInfo = await DeviceInfoPlugin().iosInfo; } - final connectivity = Connectivity(); - final localAuthentication = LocalAuthentication(); - final connectivityStatusService = - ConnectivityStatusServiceImpl(connectivity); - final localAuthService = LocalAuthenticationService(localAuthentication); + final connectivityStatusService = ConnectivityStatusServiceImpl( + Connectivity(), + ); + final localAuthService = LocalAuthenticationService( + LocalAuthentication(), + ); HydratedBloc.storage = await HydratedStorage.build( storageDirectory: await getApplicationDocumentsDirectory(), @@ -145,8 +147,12 @@ void main() async { }); final apiFactory = PaperlessApiFactoryImpl(sessionManager); - final authenticationCubit = - AuthenticationCubit(localAuthService, apiFactory, sessionManager); + final authenticationCubit = AuthenticationCubit( + localAuthService, + apiFactory, + sessionManager, + connectivityStatusService, + ); await authenticationCubit.restoreSessionState(); runApp( @@ -154,7 +160,6 @@ void main() async { providers: [ ChangeNotifierProvider.value(value: sessionManager), Provider.value(value: localAuthService), - Provider.value(value: connectivity), Provider.value( value: connectivityStatusService), Provider.value( @@ -171,6 +176,7 @@ void main() async { ), ); }, (error, stack) { + // Catches all unexpected/uncaught errors and prints them to the console. String message = switch (error) { PaperlessApiException e => e.details ?? error.toString(), ServerMessageException e => e.message, @@ -271,12 +277,22 @@ class _GoRouterShellState extends State { Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - state.when( - unauthenticated: () => _router.goNamed(R.login), - requriresLocalAuthentication: () => _router.goNamed(R.verifyIdentity), - switchingAccounts: () => _router.goNamed(R.switchingAccounts), - authenticated: (localUserId) => _router.goNamed(R.landing), - ); + switch (state) { + case UnauthenticatedState(): + const LoginRoute().go(context); + break; + + case RequiresLocalAuthenticationState(): + const VerifyIdentityRoute().go(context); + break; + case SwitchingAccountsState(): + const SwitchingAccountsRoute().go(context); + break; + case AuthenticatedState(): + const LandingRoute().go(context); + break; + case AuthenticationErrorState(): + } }, child: GlobalSettingsBuilder( builder: (context, settings) { diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart index c70fc5d7..44c0a6e9 100644 --- a/lib/routes/typed/branches/labels_route.dart +++ b/lib/routes/typed/branches/labels_route.dart @@ -100,6 +100,7 @@ class LinkedDocumentsRoute extends GoRouteData { context.read(), context.read(), context.read(), + context.read(), ), child: const LinkedDocumentsPage(), ); diff --git a/packages/mock_server/lib/mock_server.dart b/packages/mock_server/lib/mock_server.dart index f82dc3d6..84992cdd 100644 --- a/packages/mock_server/lib/mock_server.dart +++ b/packages/mock_server/lib/mock_server.dart @@ -1,13 +1,13 @@ library mock_server; -export 'response_delay_generator.dart'; +export 'response_delay_factory.dart'; import 'dart:convert'; import 'dart:math'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:mock_server/english_words.dart'; -import 'package:mock_server/response_delay_generator.dart'; +import 'package:mock_server/response_delay_factory.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_router/shelf_router.dart' as shelf_router; @@ -22,15 +22,17 @@ class LocalMockApiServer { static get baseUrl => 'http://$host:$port/'; - final DelayGenerator _delayGenerator; + final ResponseDelayFactory _delayGenerator; late shelf_router.Router app; Future> loadFixture(String name) async { - var fixture = await rootBundle.loadString('packages/mock_server/fixtures/$name.json'); + var fixture = + await rootBundle.loadString('packages/mock_server/fixtures/$name.json'); return json.decode(fixture); } - LocalMockApiServer([this._delayGenerator = const ZeroDelayGenerator()]) { + LocalMockApiServer( + [this._delayGenerator = const ZeroResponseDelayFactory()]) { app = shelf_router.Router(); Map createdTags = {}; @@ -44,7 +46,8 @@ class LocalMockApiServer { log.info('Responding to /api/token/'); var body = await req.bodyJsonMap(); if (body?['username'] == 'admin' && body?['password'] == 'test') { - return JsonMockResponse.ok({'token': 'testToken'}, _delayGenerator.nextDelay()); + return JsonMockResponse.ok( + {'token': 'testToken'}, _delayGenerator.nextDelay()); } else { return Response.unauthorized('Unauthorized'); } @@ -149,9 +152,13 @@ class LocalMockApiServer { app.delete('/api/tags//', (Request req, String tagId) async { log.info('Responding to PUT /api/tags//'); - (createdTags['results'] as List).removeWhere((element) => element['id'] == tagId); + (createdTags['results'] as List) + .removeWhere((element) => element['id'] == tagId); return Response(204, - body: null, headers: {'Content-Type': 'application/json'}, encoding: null, context: null); + body: null, + headers: {'Content-Type': 'application/json'}, + encoding: null, + context: null); }); app.get('/api/storage_paths/', (Request req) async { @@ -180,7 +187,8 @@ class LocalMockApiServer { app.get('/api/documents//thumb/', (Request req, String docId) async { log.info('Responding to /api/documents//thumb/'); - var thumb = await rootBundle.load('packages/mock_server/fixtures/lorem-ipsum.png'); + var thumb = await rootBundle + .load('packages/mock_server/fixtures/lorem-ipsum.png'); try { var resp = Response.ok( http.ByteStream.fromBytes(thumb.buffer.asInt8List()), @@ -192,14 +200,16 @@ class LocalMockApiServer { } }); - app.get('/api/documents//metadata/', (Request req, String docId) async { + app.get('/api/documents//metadata/', + (Request req, String docId) async { log.info('Responding to /api/documents//metadata/'); var data = await loadFixture('metadata'); return JsonMockResponse.ok(data, _delayGenerator.nextDelay()); }); //This is not yet used in the app - app.get('/api/documents//suggestions/', (Request req, String docId) async { + app.get('/api/documents//suggestions/', + (Request req, String docId) async { log.info('Responding to /api/documents//suggestions/'); var data = await loadFixture('suggestions'); return JsonMockResponse.ok(data, _delayGenerator.nextDelay()); @@ -235,7 +245,10 @@ class LocalMockApiServer { final term = req.url.queryParameters["term"] ?? ''; final limit = int.parse(req.url.queryParameters["limit"] ?? '5'); return JsonMockResponse.ok( - mostFrequentWords.where((element) => element.startsWith(term)).take(limit).toList(), + mostFrequentWords + .where((element) => element.startsWith(term)) + .take(limit) + .toList(), _delayGenerator.nextDelay(), ); }); diff --git a/packages/mock_server/lib/response_delay_generator.dart b/packages/mock_server/lib/response_delay_factory.dart similarity index 60% rename from packages/mock_server/lib/response_delay_generator.dart rename to packages/mock_server/lib/response_delay_factory.dart index 347c5345..1dffc82c 100644 --- a/packages/mock_server/lib/response_delay_generator.dart +++ b/packages/mock_server/lib/response_delay_factory.dart @@ -1,10 +1,10 @@ import 'dart:math'; -abstract interface class DelayGenerator { +abstract interface class ResponseDelayFactory { Duration nextDelay(); } -class RandomDelayGenerator implements DelayGenerator { +class RandomResponseDelayFactory implements ResponseDelayFactory { /// Minimum allowed response delay final Duration minDelay; @@ -12,7 +12,7 @@ class RandomDelayGenerator implements DelayGenerator { final Duration maxDelay; final Random _random = Random(); - RandomDelayGenerator(this.minDelay, this.maxDelay); + RandomResponseDelayFactory(this.minDelay, this.maxDelay); @override Duration nextDelay() { @@ -25,10 +25,10 @@ class RandomDelayGenerator implements DelayGenerator { } } -class ConstantDelayGenerator implements DelayGenerator { +class ConstantResponseDelayFactory implements ResponseDelayFactory { final Duration delay; - const ConstantDelayGenerator(this.delay); + const ConstantResponseDelayFactory(this.delay); @override Duration nextDelay() { @@ -36,8 +36,8 @@ class ConstantDelayGenerator implements DelayGenerator { } } -class ZeroDelayGenerator implements DelayGenerator { - const ZeroDelayGenerator(); +class ZeroResponseDelayFactory implements ResponseDelayFactory { + const ZeroResponseDelayFactory(); @override Duration nextDelay() { diff --git a/packages/paperless_api/lib/src/models/paged_search_result.dart b/packages/paperless_api/lib/src/models/paged_search_result.dart index d727af35..79898d07 100644 --- a/packages/paperless_api/lib/src/models/paged_search_result.dart +++ b/packages/paperless_api/lib/src/models/paged_search_result.dart @@ -51,8 +51,8 @@ class PagedSearchResult extends Equatable { const PagedSearchResult({ required this.count, - required this.next, - required this.previous, + this.next, + this.previous, required this.results, }); diff --git a/packages/paperless_api/lib/src/models/paperless_api_exception.dart b/packages/paperless_api/lib/src/models/paperless_api_exception.dart index adaa8748..136de68a 100644 --- a/packages/paperless_api/lib/src/models/paperless_api_exception.dart +++ b/packages/paperless_api/lib/src/models/paperless_api_exception.dart @@ -67,5 +67,6 @@ enum ErrorCode { uiSettingsLoadFailed, loadTasksError, userNotFound, + userAlreadyExists, updateSavedViewError; } From 37ed8bbb04bc75156ea734aa9ed99fd94eb33492 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Mon, 2 Oct 2023 23:59:42 +0200 Subject: [PATCH 10/12] feat: Implement updated receive share logic --- lib/core/config/hive/hive_config.dart | 1 - lib/core/config/hive/hive_extensions.dart | 18 + lib/core/database/tables/global_settings.dart | 4 + lib/core/global/constants.dart | 9 +- lib/core/service/file_service.dart | 52 +- lib/core/widgets/future_or_builder.dart | 35 + lib/features/app_drawer/view/app_drawer.dart | 77 ++ .../document_scan/view/scanner_page.dart | 2 +- .../document_upload_preparation_page.dart | 34 +- .../documents/view/pages/documents_page.dart | 346 +++++---- .../saved_views/saved_views_widget.dart | 2 +- lib/features/home/view/home_shell_widget.dart | 10 +- .../login/cubit/authentication_cubit.dart | 3 +- .../services/local_notification_service.dart | 3 +- .../cubit/saved_view_preview_cubit.dart | 2 - .../view/saved_view_preview.dart | 2 + lib/features/settings/view/settings_page.dart | 2 + .../widgets/color_scheme_option_setting.dart | 2 - ...ocument_prepraration_on_share_setting.dart | 24 + .../sharing/cubit/receive_share_cubit.dart | 72 ++ .../sharing/cubit/receive_share_state.dart | 32 + .../sharing/logic/upload_queue_processor.dart | 73 ++ .../sharing/model/share_intent_queue.dart | 105 +++ lib/features/sharing/share_intent_queue.dart | 56 -- .../sharing/view/consumption_queue_view.dart | 110 +++ .../dialog/discard_shared_file_dialog.dart | 51 ++ .../sharing/view/widgets/file_thumbnail.dart | 102 +++ .../view/widgets/upload_queue_shell.dart | 195 +++++ .../tasks/cubit/task_status_cubit.dart | 44 +- .../tasks/cubit/task_status_state.dart | 31 - lib/l10n/intl_ca.arb | 39 +- lib/l10n/intl_cs.arb | 9 +- lib/l10n/intl_de.arb | 9 +- lib/l10n/intl_en.arb | 9 +- lib/l10n/intl_es.arb | 9 +- lib/l10n/intl_fr.arb | 9 +- lib/l10n/intl_pl.arb | 9 +- lib/l10n/intl_ru.arb | 729 +++++++++--------- lib/l10n/intl_tr.arb | 9 +- lib/main.dart | 32 +- lib/routes/routes.dart | 1 + lib/routes/typed/branches/scanner_route.dart | 4 +- .../typed/branches/upload_queue_route.dart | 20 + .../typed/shells/provider_shell_route.dart | 12 +- .../lib/src/models/task/task.dart | 2 + pubspec.lock | 16 + pubspec.yaml | 2 + 47 files changed, 1692 insertions(+), 727 deletions(-) create mode 100644 lib/core/widgets/future_or_builder.dart create mode 100644 lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart create mode 100644 lib/features/sharing/cubit/receive_share_cubit.dart create mode 100644 lib/features/sharing/cubit/receive_share_state.dart create mode 100644 lib/features/sharing/logic/upload_queue_processor.dart create mode 100644 lib/features/sharing/model/share_intent_queue.dart delete mode 100644 lib/features/sharing/share_intent_queue.dart create mode 100644 lib/features/sharing/view/consumption_queue_view.dart create mode 100644 lib/features/sharing/view/dialog/discard_shared_file_dialog.dart create mode 100644 lib/features/sharing/view/widgets/file_thumbnail.dart create mode 100644 lib/features/sharing/view/widgets/upload_queue_shell.dart delete mode 100644 lib/features/tasks/cubit/task_status_state.dart create mode 100644 lib/routes/typed/branches/upload_queue_route.dart diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart index c0d8f7b7..8952bc90 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/config/hive/hive_config.dart @@ -15,7 +15,6 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart'; class HiveBoxes { HiveBoxes._(); static const globalSettings = 'globalSettings'; - static const authentication = 'authentication'; static const localUserCredentials = 'localUserCredentials'; static const localUserAccount = 'localUserAccount'; static const localUserAppState = 'localUserAppState'; diff --git a/lib/core/config/hive/hive_extensions.dart b/lib/core/config/hive/hive_extensions.dart index ee440856..83b88235 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/config/hive/hive_extensions.dart @@ -4,6 +4,11 @@ import 'dart:typed_data'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; /// /// Opens an encrypted box, calls [callback] with the now opened box, awaits @@ -40,3 +45,16 @@ Future _getEncryptedBoxKey() async { final key = (await secureStorage.read(key: 'key'))!; return base64Decode(key); } + +extension HiveBoxAccessors on HiveInterface { + Box get settingsBox => + box(HiveBoxes.globalSettings); + Box get localUserAccountBox => + box(HiveBoxes.localUserAccount); + Box get localUserAppStateBox => + box(HiveBoxes.localUserAppState); + Box get localUserSettingsBox => + box(HiveBoxes.localUserSettings); + Box get globalSettingsBox => + box(HiveBoxes.globalSettings); +} diff --git a/lib/core/database/tables/global_settings.dart b/lib/core/database/tables/global_settings.dart index dda69614..fdcccbc5 100644 --- a/lib/core/database/tables/global_settings.dart +++ b/lib/core/database/tables/global_settings.dart @@ -32,6 +32,9 @@ class GlobalSettings with HiveObjectMixin { @HiveField(7, defaultValue: false) bool enforceSinglePagePdfUpload; + @HiveField(8, defaultValue: false) + bool skipDocumentPreprarationOnUpload; + GlobalSettings({ required this.preferredLocaleSubtag, this.preferredThemeMode = ThemeMode.system, @@ -41,5 +44,6 @@ class GlobalSettings with HiveObjectMixin { this.defaultDownloadType = FileDownloadType.alwaysAsk, this.defaultShareType = FileDownloadType.alwaysAsk, this.enforceSinglePagePdfUpload = false, + this.skipDocumentPreprarationOnUpload = false, }); } diff --git a/lib/core/global/constants.dart b/lib/core/global/constants.dart index 652572fe..04230a77 100644 --- a/lib/core/global/constants.dart +++ b/lib/core/global/constants.dart @@ -1 +1,8 @@ -const supportedFileExtensions = ['pdf', 'png', 'tiff', 'gif', 'jpg', 'jpeg']; +const supportedFileExtensions = [ + '.pdf', + '.png', + '.tiff', + '.gif', + '.jpg', + '.jpeg' +]; diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 00145a8a..61d78320 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -3,9 +3,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; class FileService { + const FileService._(); + static Future saveToFile( Uint8List bytes, String filename, @@ -19,16 +22,13 @@ class FileService { } static Future getDirectory(PaperlessDirectoryType type) { - switch (type) { - case PaperlessDirectoryType.documents: - return documentsDirectory; - case PaperlessDirectoryType.temporary: - return temporaryDirectory; - case PaperlessDirectoryType.scans: - return temporaryScansDirectory; - case PaperlessDirectoryType.download: - return downloadsDirectory; - } + return switch (type) { + PaperlessDirectoryType.documents => documentsDirectory, + PaperlessDirectoryType.temporary => temporaryDirectory, + PaperlessDirectoryType.scans => temporaryScansDirectory, + PaperlessDirectoryType.download => downloadsDirectory, + PaperlessDirectoryType.upload => uploadDirectory, + }; } static Future allocateTemporaryFile( @@ -50,8 +50,8 @@ class FileService { ))! .first; } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/documents'); + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/documents')); return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); @@ -77,17 +77,32 @@ class FileService { } } + static Future get uploadDirectory async { + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/upload')); + return dir.create(recursive: true); + } + + static Future getConsumptionDirectory( + {required String userId}) async { + final uploadDir = + await uploadDirectory.then((dir) => Directory('${dir.path}/$userId')); + return uploadDir.create(recursive: true); + } + static Future get temporaryScansDirectory async { final tempDir = await temporaryDirectory; final scansDir = Directory('${tempDir.path}/scans'); return scansDir.create(recursive: true); } - static Future clearUserData() async { + static Future clearUserData({required String userId}) async { final scanDir = await temporaryScansDirectory; final tempDir = await temporaryDirectory; + final consumptionDir = await getConsumptionDirectory(userId: userId); await scanDir.delete(recursive: true); await tempDir.delete(recursive: true); + await consumptionDir.delete(recursive: true); } static Future clearDirectoryContent(PaperlessDirectoryType type) async { @@ -101,11 +116,20 @@ class FileService { dir.listSync().map((item) => item.delete(recursive: true)), ); } + + static Future> getAllFiles(Directory directory) { + return directory.list().whereType().toList(); + } + + static Future> getAllSubdirectories(Directory directory) { + return directory.list().whereType().toList(); + } } enum PaperlessDirectoryType { documents, temporary, scans, - download; + download, + upload; } diff --git a/lib/core/widgets/future_or_builder.dart b/lib/core/widgets/future_or_builder.dart new file mode 100644 index 00000000..4651f5d5 --- /dev/null +++ b/lib/core/widgets/future_or_builder.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class FutureOrBuilder extends StatelessWidget { + final FutureOr? futureOrValue; + + final T? initialData; + + final AsyncWidgetBuilder builder; + + const FutureOrBuilder({ + super.key, + FutureOr? future, + this.initialData, + required this.builder, + }) : futureOrValue = future; + + @override + Widget build(BuildContext context) { + final futureOrValue = this.futureOrValue; + if (futureOrValue is T) { + return builder( + context, + AsyncSnapshot.withData(ConnectionState.done, futureOrValue), + ); + } else { + return FutureBuilder( + future: futureOrValue, + initialData: initialData, + builder: builder, + ); + } + } +} diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 11402a92..14d0a649 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -1,11 +1,19 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; class AppDrawer extends StatelessWidget { @@ -16,6 +24,7 @@ class AppDrawer extends StatelessWidget { return SafeArea( child: Drawer( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ @@ -93,6 +102,29 @@ class AppDrawer extends StatelessWidget { ); }, ), + Consumer( + builder: (context, value, child) { + final files = value.pendingFiles; + final child = ListTile( + dense: true, + leading: const Icon(Icons.drive_folder_upload_outlined), + title: const Text("Upload Queue"), + onTap: () { + UploadQueueRoute().push(context); + }, + trailing: Text( + '${files.length}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + if (files.isEmpty) { + return child; + } + return child + .animate(onPlay: (c) => c.repeat(reverse: true)) + .fade(duration: 1.seconds, begin: 1, end: 0.3); + }, + ), ListTile( dense: true, leading: const Icon(Icons.settings_outlined), @@ -101,12 +133,57 @@ class AppDrawer extends StatelessWidget { ), onTap: () => SettingsRoute().push(context), ), + const Divider(), + Text( + S.of(context)!.views, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.labelLarge, + ).padded(16), + _buildSavedViews(), ], ), ), ); } + Widget _buildSavedViews() { + return BlocBuilder( + builder: (context, state) { + return state.when( + initial: () => const SizedBox.shrink(), + loading: () => const Center(child: CircularProgressIndicator()), + loaded: (savedViews) { + final sidebarViews = savedViews.values + .where((element) => element.showInSidebar) + .toList(); + if (sidebarViews.isEmpty) { + return Text("Nothing to show here.").paddedOnly(left: 16); + } + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final view = sidebarViews[index]; + return ListTile( + title: Text(view.name), + trailing: Icon(Icons.arrow_forward), + onTap: () { + Scaffold.of(context).closeDrawer(); + context + .read() + .updateFilter(filter: view.toDocumentFilter()); + DocumentsRoute().go(context); + }, + ); + }, + itemCount: sidebarViews.length, + ), + ); + }, + error: () => Text(S.of(context)!.couldNotLoadSavedViews), + ); + }); + } + void _showAboutDialog(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 4ea62ca9..0a5d788a 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -265,7 +265,7 @@ class _ScannerPageState extends State // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); context - .read() + .read() .listenToTaskChanges(uploadResult!.taskId!); } } diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 0503da4f..4412b0ca 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; @@ -20,6 +22,7 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -33,7 +36,7 @@ class DocumentUploadResult { } class DocumentUploadPreparationPage extends StatefulWidget { - final Uint8List fileBytes; + final FutureOr fileBytes; final String? title; final String? filename; final String? fileExtension; @@ -68,9 +71,10 @@ class _DocumentUploadPreparationPageState void initState() { super.initState(); _syncTitleAndFilename = widget.filename == null && widget.title == null; - _titleColor = _computeAverageColor().computeLuminance() > 0.5 - ? Colors.black - : Colors.white; + _computeAverageColor().then((value) { + _titleColor = + value.computeLuminance() > 0.5 ? Colors.black : Colors.white; + }); initializeDateFormatting(); } @@ -104,9 +108,17 @@ class _DocumentUploadPreparationPageState pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( - background: Image.memory( - widget.fileBytes, - fit: BoxFit.cover, + background: FutureOrBuilder( + future: widget.fileBytes, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + return FileThumbnail( + bytes: snapshot.data!, + fit: BoxFit.fitWidth, + ); + }, ), title: Text( S.of(context)!.prepareDocument, @@ -116,7 +128,7 @@ class _DocumentUploadPreparationPageState ), ), bottom: _isUploadLoading - ? const PreferredSize( + ? PreferredSize( child: LinearProgressIndicator(), preferredSize: Size.fromHeight(4.0), ) @@ -359,7 +371,7 @@ class _DocumentUploadPreparationPageState ?.whenOrNull(fromId: (id) => id); final asn = fv[DocumentModel.asnKey] as int?; final taskId = await cubit.upload( - widget.fileBytes, + await widget.fileBytes, filename: _padWithExtension( _formKey.currentState?.value[fkFileName], widget.fileExtension, @@ -404,8 +416,8 @@ class _DocumentUploadPreparationPageState return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase(); } - Color _computeAverageColor() { - final bitmap = img.decodeImage(widget.fileBytes); + Future _computeAverageColor() async { + final bitmap = img.decodeImage(await widget.fileBytes); if (bitmap == null) { return Colors.black; } diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index d506060e..4aca245c 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -59,18 +59,48 @@ class _DocumentsPageState extends State { @override void initState() { super.initState(); + context.read().addListener(_onTasksChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _nestedScrollViewKey.currentState!.innerController .addListener(_scrollExtentChangedListener); }); } + void _onTasksChanged() { + final notifier = context.read(); + final tasks = notifier.value; + final finishedTasks = tasks.values.where((element) => element.isSuccess); + if (finishedTasks.isNotEmpty) { + showSnackBar( + context, + S.of(context)!.newDocumentAvailable, + action: SnackBarActionConfig( + label: S.of(context)!.reload, + onPressed: () { + // finishedTasks.forEach((task) { + // notifier.acknowledgeTasks([finishedTasks]); + // }); + context.read().reload(); + }, + ), + duration: const Duration(seconds: 10), + ); + } + } + Future _reloadData() async { + final user = context.read().paperlessUser; try { await Future.wait([ context.read().reload(), - context.read().reload(), - context.read().reload(), + if (user.canViewSavedViews) context.read().reload(), + if (user.canViewTags) context.read().reloadTags(), + if (user.canViewCorrespondents) + context.read().reloadCorrespondents(), + if (user.canViewDocumentTypes) + context.read().reloadDocumentTypes(), + if (user.canViewStoragePaths) + context.read().reloadStoragePaths(), ]); } catch (error, stackTrace) { showGenericError(context, error, stackTrace); @@ -96,196 +126,174 @@ class _DocumentsPageState extends State { void dispose() { _nestedScrollViewKey.currentState?.innerController .removeListener(_scrollExtentChangedListener); + context.read().removeListener(_onTasksChanged); + super.dispose(); } @override Widget build(BuildContext context) { - return BlocListener( + return BlocConsumer( listenWhen: (previous, current) => - !previous.isSuccess && current.isSuccess, + previous != ConnectivityState.connected && + current == ConnectivityState.connected, listener: (context, state) { - showSnackBar( - context, - S.of(context)!.newDocumentAvailable, - action: SnackBarActionConfig( - label: S.of(context)!.reload, - onPressed: () { - context.read().acknowledgeCurrentTask(); - context.read().reload(); - }, - ), - duration: const Duration(seconds: 10), - ); + _reloadData(); }, - child: BlocConsumer( - listenWhen: (previous, current) => - previous != ConnectivityState.connected && - current == ConnectivityState.connected, - listener: (context, state) { - _reloadData(); - }, - builder: (context, connectivityState) { - return SafeArea( - top: true, - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: BlocBuilder( - builder: (context, state) { - final show = state.selection.isEmpty; - final canReset = state.filter.appliedFiltersCount > 0; - if (show) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - DeferredPointerHandler( - child: Stack( - clipBehavior: Clip.none, - children: [ - FloatingActionButton.extended( - extendedPadding: _showExtendedFab - ? null - : const EdgeInsets.symmetric( - horizontal: 16), - heroTag: "fab_documents_page_filter", - label: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: child, - ), - ); - }, - child: _showExtendedFab - ? Row( - children: [ - const Icon( - Icons.filter_alt_outlined, - ), - const SizedBox(width: 8), - Text( - S.of(context)!.filterDocuments, - ), - ], - ) - : const Icon(Icons.filter_alt_outlined), - ), - onPressed: _openDocumentFilter, + builder: (context, connectivityState) { + return SafeArea( + top: true, + child: Scaffold( + drawer: const AppDrawer(), + floatingActionButton: BlocBuilder( + builder: (context, state) { + final show = state.selection.isEmpty; + final canReset = state.filter.appliedFiltersCount > 0; + if (show) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DeferredPointerHandler( + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton.extended( + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric(horizontal: 16), + heroTag: "fab_documents_page_filter", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon( + Icons.filter_alt_outlined, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.filterDocuments, + ), + ], + ) + : const Icon(Icons.filter_alt_outlined), ), - if (canReset) - Positioned( - top: -20, - right: -8, - child: DeferPointer( - paintOnTop: true, - child: Material( - color: - Theme.of(context).colorScheme.error, + onPressed: _openDocumentFilter, + ), + if (canReset) + Positioned( + top: -20, + right: -8, + child: DeferPointer( + paintOnTop: true, + child: Material( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(8), + child: InkWell( borderRadius: BorderRadius.circular(8), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - HapticFeedback.mediumImpact(); - _onResetFilter(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - if (_showExtendedFab) - Text( - "Reset (${state.filter.appliedFiltersCount})", - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onError, - ), - ).padded() - else - Icon( - Icons.replay, - color: Theme.of(context) - .colorScheme - .onError, - ).padded(4), - ], - ), + onTap: () { + HapticFeedback.mediumImpact(); + _onResetFilter(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + if (_showExtendedFab) + Text( + "Reset (${state.filter.appliedFiltersCount})", + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onError, + ), + ).padded() + else + Icon( + Icons.replay, + color: Theme.of(context) + .colorScheme + .onError, + ).padded(4), + ], ), ), ), ), - ], - ), + ), + ], ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - resizeToAvoidBottomInset: true, - body: WillPopScope( - onWillPop: () async { - if (context - .read() - .state - .selection - .isNotEmpty) { - context.read().resetSelection(); - return false; - } - return true; - }, - child: NestedScrollView( - key: _nestedScrollViewKey, - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return SliverSearchBar( - floating: true, - titleText: S.of(context)!.documents, - ); - } else { - return DocumentSelectionSliverAppBar( - state: state, - ); - } - }, ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + resizeToAvoidBottomInset: true, + body: WillPopScope( + onWillPop: () async { + if (context.read().state.selection.isNotEmpty) { + context.read().resetSelection(); + return false; + } + return true; + }, + child: NestedScrollView( + key: _nestedScrollViewKey, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: BlocBuilder( + builder: (context, state) { + if (state.selection.isEmpty) { + return SliverSearchBar( + floating: true, + titleText: S.of(context)!.documents, + ); + } else { + return DocumentSelectionSliverAppBar( + state: state, + ); + } + }, ), - SliverOverlapAbsorber( - handle: savedViewsHandle, - sliver: SliverPinnedHeader( - child: Material( - child: _buildViewActions(), - elevation: 2, - ), + ), + SliverOverlapAbsorber( + handle: savedViewsHandle, + sliver: SliverPinnedHeader( + child: Material( + child: _buildViewActions(), + elevation: 2, ), ), - ], - body: _buildDocumentsTab( - connectivityState, - context, ), + ], + body: _buildDocumentsTab( + connectivityState, + context, ), ), ), - ); - }, - ), + ), + ); + }, ); } diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 0480d17a..56ee1877 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -126,7 +126,7 @@ class _SavedViewsWidgetState extends State .maybeMap( loaded: (value) { if (value.savedViews.isEmpty) { - return Text(S.of(context)!.noItemsFound) + return Text(S.of(context)!.youDidNotSaveAnyViewsYet) .paddedOnly(left: 16); } diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index e73e3028..07709d44 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -47,8 +47,8 @@ class HomeShellWidget extends StatelessWidget { builder: (context, settings) { final currentUserId = settings.loggedInUserId; if (currentUserId == null) { - // This is the case when the current user logs out of the app. - return SizedBox.shrink(); + // This is currently the case (only for a few ms) when the current user logs out of the app. + return const SizedBox.shrink(); } final apiVersion = ApiVersion(paperlessApiVersion); return ValueListenableBuilder( @@ -57,8 +57,6 @@ class HomeShellWidget extends StatelessWidget { .listenable(keys: [currentUserId]), builder: (context, box, _) { final currentLocalUser = box.get(currentUserId)!; - print(currentLocalUser.paperlessUser.canViewDocuments); - print(currentLocalUser.paperlessUser.canViewTags); return MultiProvider( key: ValueKey(currentUserId), providers: [ @@ -195,8 +193,8 @@ class HomeShellWidget extends StatelessWidget { context.read(), ), ), - Provider( - create: (context) => TaskStatusCubit( + ChangeNotifierProvider( + create: (context) => PendingTasksNotifier( context.read(), ), ), diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index cc695f9c..177ca7d8 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -17,6 +17,7 @@ import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; @@ -159,7 +160,7 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAccount); final userAppStateBox = Hive.box(HiveBoxes.localUserAppState); - + await FileService.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); await withEncryptedBox( diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index e651bb9a..f4e7f857 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -133,7 +133,6 @@ class LocalNotificationService { ); } - //TODO: INTL Future notifyTaskChanged(Task task) { log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); @@ -158,7 +157,7 @@ class LocalNotificationService { break; case TaskStatus.failure: title = "Failed to process document"; - body = "Document ${task.taskFileName} was rejected by the server."; + body = task.result ?? 'Rejected by the server.'; timestampMillis = task.dateCreated.millisecondsSinceEpoch; break; case TaskStatus.success: diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index cffd03d3..2bd8fcd9 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -1,10 +1,8 @@ import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'saved_view_preview_state.dart'; -part 'saved_view_preview_cubit.freezed.dart'; class SavedViewPreviewCubit extends Cubit { final PaperlessDocumentsApi _api; diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 2ebb4598..ad1ff930 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index a0d5ce44..5be033ce 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/features/settings/view/widgets/default_download import 'package:paperless_mobile/features/settings/view/widgets/default_share_file_type_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/enforce_pdf_upload_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/language_selection_setting.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/theme_mode_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -33,6 +34,7 @@ class SettingsPage extends StatelessWidget { const DefaultDownloadFileTypeSetting(), const DefaultShareFileTypeSetting(), const EnforcePdfUploadSetting(), + const SkipDocumentPreprationOnShareSetting(), _buildSectionHeader(context, S.of(context)!.storage), const ClearCacheSetting(), ], diff --git a/lib/features/settings/view/widgets/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart index 4bb28146..5a6f2271 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; @@ -9,7 +8,6 @@ import 'package:paperless_mobile/features/settings/model/color_scheme_option.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/theme.dart'; class ColorSchemeOptionSetting extends StatelessWidget { const ColorSchemeOptionSetting({super.key}); diff --git a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart new file mode 100644 index 00000000..0d035c68 --- /dev/null +++ b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; + +class SkipDocumentPreprationOnShareSetting extends StatelessWidget { + const SkipDocumentPreprationOnShareSetting({super.key}); + + @override + Widget build(BuildContext context) { + return GlobalSettingsBuilder( + builder: (context, settings) { + return SwitchListTile( + title: Text("Direct share"), + subtitle: + Text("Always directly upload when sharing files with the app."), + value: settings.skipDocumentPreprarationOnUpload, + onChanged: (value) { + settings.skipDocumentPreprarationOnUpload = value; + settings.save(); + }, + ); + }, + ); + } +} diff --git a/lib/features/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart new file mode 100644 index 00000000..a6322e64 --- /dev/null +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +part 'receive_share_state.dart'; + +class ConsumptionChangeNotifier extends ChangeNotifier { + List pendingFiles = []; + + ConsumptionChangeNotifier(); + + Future loadFromConsumptionDirectory({required String userId}) async { + pendingFiles = await _getCurrentFiles(userId); + notifyListeners(); + } + + /// Creates a local copy of all shared files and reloads all files + /// from the user's consumption directory. + Future addFiles({ + required List files, + required String userId, + }) async { + if (files.isEmpty) { + return; + } + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + for (final file in files) { + File localFile; + if (file.path.startsWith(consumptionDirectory.path)) { + localFile = file; + } else { + final fileName = p.basename(file.path); + localFile = File(p.join(consumptionDirectory.path, fileName)); + await file.copy(localFile.path); + } + } + return loadFromConsumptionDirectory(userId: userId); + } + + /// Marks a file as processed by removing it from the queue and deleting the local copy of the file. + Future discardFile( + File file, { + required String userId, + }) async { + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + if (file.path.startsWith(consumptionDirectory.path)) { + await file.delete(); + } + return loadFromConsumptionDirectory(userId: userId); + } + + /// Returns the next file to process of null if no file exists. + Future getNextFile({required String userId}) async { + final files = await _getCurrentFiles(userId); + if (files.isEmpty) { + return null; + } + return files.first; + } + + Future> _getCurrentFiles(String userId) async { + final directory = await FileService.getConsumptionDirectory(userId: userId); + final files = await FileService.getAllFiles(directory); + return files; + } +} diff --git a/lib/features/sharing/cubit/receive_share_state.dart b/lib/features/sharing/cubit/receive_share_state.dart new file mode 100644 index 00000000..c17ce2b3 --- /dev/null +++ b/lib/features/sharing/cubit/receive_share_state.dart @@ -0,0 +1,32 @@ +part of 'receive_share_cubit.dart'; + +sealed class ReceiveShareState { + final List files; + + const ReceiveShareState({this.files = const []}); +} + +class ReceiveShareStateInitial extends ReceiveShareState { + const ReceiveShareStateInitial(); +} + +class ReceiveShareStateLoading extends ReceiveShareState { + const ReceiveShareStateLoading(); +} + +class ReceiveShareStateLoaded extends ReceiveShareState { + const ReceiveShareStateLoaded({super.files}); + + ReceiveShareStateLoaded copyWith({ + List? files, + }) { + return ReceiveShareStateLoaded( + files: files ?? this.files, + ); + } +} + +class ReceiveShareStateError extends ReceiveShareState { + final String message; + const ReceiveShareStateError(this.message); +} diff --git a/lib/features/sharing/logic/upload_queue_processor.dart b/lib/features/sharing/logic/upload_queue_processor.dart new file mode 100644 index 00000000..89b06386 --- /dev/null +++ b/lib/features/sharing/logic/upload_queue_processor.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/global/constants.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; +import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:path/path.dart' as p; + +class UploadQueueProcessor { + final ShareIntentQueue queue; + + UploadQueueProcessor({required this.queue}); + + bool _isFileTypeSupported(File file) { + final isSupported = + supportedFileExtensions.contains(p.extension(file.path)); + return isSupported; + } + + void processIncomingFiles( + BuildContext context, { + required List sharedFiles, + }) async { + if (sharedFiles.isEmpty) { + return; + } + Iterable files = sharedFiles.map((file) => File(file.path)); + if (Platform.isIOS) { + files = files + .map((file) => File(file.path.replaceAll('file://', ''))) + .toList(); + } + final supportedFiles = files.where(_isFileTypeSupported); + final unsupportedFiles = files.whereNot(_isFileTypeSupported); + debugPrint( + "Received ${files.length} files, out of which ${supportedFiles.length} are supported.}"); + if (supportedFiles.isEmpty) { + Fluttertoast.showToast( + msg: translateError( + context, + ErrorCode.unsupportedFileFormat, + ), + ); + if (Platform.isAndroid) { + // As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines. + await SystemNavigator.pop(); + } + return; + } + if (unsupportedFiles.isNotEmpty) { + //TODO: INTL + Fluttertoast.showToast( + msg: + "${unsupportedFiles.length}/${files.length} files could not be processed."); + } + await ShareIntentQueue.instance.addAll( + supportedFiles, + userId: context.read().id, + ); + } +} diff --git a/lib/features/sharing/model/share_intent_queue.dart b/lib/features/sharing/model/share_intent_queue.dart new file mode 100644 index 00000000..6bd29a21 --- /dev/null +++ b/lib/features/sharing/model/share_intent_queue.dart @@ -0,0 +1,105 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:path/path.dart' as p; + +class ShareIntentQueue extends ChangeNotifier { + final Map> _queues = {}; + + ShareIntentQueue._(); + + static final instance = ShareIntentQueue._(); + + Future initialize() async { + final users = Hive.localUserAccountBox.values; + for (final user in users) { + final userId = user.id; + debugPrint("Locating remaining files to be uploaded for $userId..."); + final consumptionDir = + await FileService.getConsumptionDirectory(userId: userId); + final files = await FileService.getAllFiles(consumptionDir); + debugPrint( + "Found ${files.length} files to be uploaded for $userId. Adding to queue..."); + getQueue(userId).addAll(files); + } + } + + void add( + File file, { + required String userId, + }) => + addAll([file], userId: userId); + + Future addAll( + Iterable files, { + required String userId, + }) async { + if (files.isEmpty) { + return; + } + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + final copiedFiles = await Future.wait([ + for (var file in files) + file.copy('${consumptionDirectory.path}/${p.basename(file.path)}') + ]); + + debugPrint( + "Adding received files to queue: ${files.map((e) => e.path).join(",")}", + ); + getQueue(userId).addAll(copiedFiles); + notifyListeners(); + } + + /// Removes and returns the first item in the requested user's queue if it exists. + File? pop(String userId) { + if (hasUnhandledFiles(userId: userId)) { + final file = getQueue(userId).removeFirst(); + notifyListeners(); + return file; + // Don't notify listeners, only when new item is added. + } + return null; + } + + Future onConsumed(File file) { + debugPrint( + "File ${file.path} successfully consumed. Delelting local copy."); + return file.delete(); + } + + Future discard(File file) { + debugPrint("Discarding file ${file.path}."); + return file.delete(); + } + + /// Returns whether the queue of the requested user contains files waiting for processing. + bool hasUnhandledFiles({ + required String userId, + }) => + getQueue(userId).isNotEmpty; + + int unhandledFileCount({ + required String userId, + }) => + getQueue(userId).length; + + Queue getQueue(String userId) { + if (!_queues.containsKey(userId)) { + _queues[userId] = Queue(); + } + return _queues[userId]!; + } +} + +class UserAwareShareMediaFile { + final String userId; + final SharedMediaFile sharedFile; + + UserAwareShareMediaFile(this.userId, this.sharedFile); +} diff --git a/lib/features/sharing/share_intent_queue.dart b/lib/features/sharing/share_intent_queue.dart deleted file mode 100644 index e551283d..00000000 --- a/lib/features/sharing/share_intent_queue.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/widgets.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; - -class ShareIntentQueue extends ChangeNotifier { - final Map> _queues = {}; - - ShareIntentQueue._(); - - static final instance = ShareIntentQueue._(); - - void add( - SharedMediaFile file, { - required String userId, - }) { - debugPrint("Adding received file to queue: ${file.path}"); - _getQueue(userId).add(file); - notifyListeners(); - } - - void addAll( - Iterable files, { - required String userId, - }) { - debugPrint( - "Adding received files to queue: ${files.map((e) => e.path).join(",")}"); - _getQueue(userId).addAll(files); - notifyListeners(); - } - - SharedMediaFile? pop(String userId) { - if (userHasUnhandlesFiles(userId)) { - return _getQueue(userId).removeFirst(); - // Don't notify listeners, only when new item is added. - } else { - return null; - } - } - - Queue _getQueue(String userId) { - if (!_queues.containsKey(userId)) { - _queues[userId] = Queue(); - } - return _queues[userId]!; - } - - bool userHasUnhandlesFiles(String userId) => _getQueue(userId).isNotEmpty; -} - -class UserAwareShareMediaFile { - final String userId; - final SharedMediaFile sharedFile; - - UserAwareShareMediaFile(this.userId, this.sharedFile); -} diff --git a/lib/features/sharing/view/consumption_queue_view.dart b/lib/features/sharing/view/consumption_queue_view.dart new file mode 100644 index 00000000..c33527d1 --- /dev/null +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +class ConsumptionQueueView extends StatelessWidget { + const ConsumptionQueueView({super.key}); + + @override + Widget build(BuildContext context) { + final currentUser = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text("Upload Queue"), //TODO: INTL + ), + body: Consumer( + builder: (context, value, child) { + if (value.pendingFiles.isEmpty) { + return Center( + child: Text("No pending files."), + ); + } + return ListView.builder( + itemBuilder: (context, index) { + final file = value.pendingFiles.elementAt(index); + final filename = p.basename(file.path); + return ListTile( + title: Text(filename), + leading: Padding( + padding: const EdgeInsets.all(4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FileThumbnail( + file: file, + fit: BoxFit.cover, + width: 75, + ), + ), + ), + trailing: IconButton( + icon: Icon(Icons.delete), + onPressed: () { + context + .read() + .discardFile(file, userId: currentUser.id); + }, + ), + ); + return Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + children: [ + Text(filename, maxLines: 1), + SizedBox( + height: 56, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ActionChip( + label: Text(S.of(context)!.upload), + avatar: Icon(Icons.file_upload_outlined), + onPressed: () { + consumeLocalFile( + context, + file: file, + userId: currentUser.id, + ); + }, + ), + SizedBox(width: 8), + ActionChip( + label: Text(S.of(context)!.discard), + avatar: Icon(Icons.delete), + onPressed: () { + context + .read() + .discardFile( + file, + userId: currentUser.id, + ); + }, + ), + ], + ), + ), + ], + ).padded(), + ), + ], + ).padded(); + }, + itemCount: value.pendingFiles.length, + ); + }, + ), + ); + } +} diff --git a/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart new file mode 100644 index 00000000..311172ee --- /dev/null +++ b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.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/future_or_builder.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class DiscardSharedFileDialog extends StatelessWidget { + final FutureOr bytes; + const DiscardSharedFileDialog({ + super.key, + required this.bytes, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: FutureOrBuilder( + future: bytes, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const CircularProgressIndicator(); + } + return LimitedBox( + maxHeight: 200, + maxWidth: 200, + child: FadeInImage( + fit: BoxFit.contain, + placeholder: MemoryImage(kTransparentImage), + image: MemoryImage(snapshot.data!), + ), + ); + }, + ), + title: Text(S.of(context)!.discardFile), + content: Text( + "The shared file was not yet processed. Do you want to discrad the file?", //TODO: INTL + ), + actions: [ + DialogCancelButton(), + DialogConfirmButton( + label: S.of(context)!.discard, + style: DialogConfirmButtonStyle.danger, + ), + ], + ); + } +} diff --git a/lib/features/sharing/view/widgets/file_thumbnail.dart b/lib/features/sharing/view/widgets/file_thumbnail.dart new file mode 100644 index 00000000..f4e8c91a --- /dev/null +++ b/lib/features/sharing/view/widgets/file_thumbnail.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mime/mime.dart' as mime; +import 'package:printing/printing.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class FileThumbnail extends StatefulWidget { + final File? file; + final Uint8List? bytes; + + final BoxFit? fit; + final double? width; + final double? height; + const FileThumbnail({ + super.key, + this.file, + this.bytes, + this.fit, + this.width, + this.height, + }) : assert((bytes != null) != (file != null)); + + @override + State createState() => _FileThumbnailState(); +} + +class _FileThumbnailState extends State { + late String? mimeType; + + @override + void initState() { + super.initState(); + mimeType = widget.file != null + ? mime.lookupMimeType(widget.file!.path) + : mime.lookupMimeType('', headerBytes: widget.bytes); + } + + @override + Widget build(BuildContext context) { + return switch (mimeType) { + "application/pdf" => SizedBox( + width: widget.width, + height: widget.height, + child: Center( + child: FutureBuilder( + future: widget.file?.readAsBytes().then(_convertPdfToPng) ?? + _convertPdfToPng(widget.bytes!), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + return ColoredBox( + color: Colors.white, + child: Image.memory( + snapshot.data!, + alignment: Alignment.topCenter, + fit: widget.fit, + width: widget.width, + height: widget.height, + ), + ); + }, + ), + ), + ), + "image/png" || + "image/jpeg" || + "image/tiff" || + "image/gif" || + "image/webp" => + widget.file != null + ? Image.file( + widget.file!, + fit: widget.fit, + width: widget.width, + height: widget.height, + ) + : Image.memory( + widget.bytes!, + fit: widget.fit, + width: widget.width, + height: widget.height, + ), + "text/plain" => const Center( + child: Text(".txt"), + ), + _ => const Icon(Icons.file_present_outlined), + }; + } + + // send pdfFile as params + Future _convertPdfToPng(Uint8List bytes) async { + final info = await Printing.info(); + if (!info.canRaster) { + return kTransparentImage; + } + final raster = await Printing.raster(bytes, pages: [0], dpi: 72).first; + return raster.toPng(); + } +} diff --git a/lib/features/sharing/view/widgets/upload_queue_shell.dart b/lib/features/sharing/view/widgets/upload_queue_shell.dart new file mode 100644 index 00000000..05c384fb --- /dev/null +++ b/lib/features/sharing/view/widgets/upload_queue_shell.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; +import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; +import 'package:path/path.dart' as p; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +class UploadQueueShell extends StatefulWidget { + final Widget child; + const UploadQueueShell({super.key, required this.child}); + + @override + State createState() => _UploadQueueShellState(); +} + +class _UploadQueueShellState extends State { + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); + _subscription = + ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); + + // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // context.read().loadFromConsumptionDirectory( + // userId: context.read().id, + // ); + // final state = context.read().state; + // print("Current state is " + state.toString()); + // final files = state.files; + // if (files.isNotEmpty) { + // showSnackBar( + // context, + // "You have ${files.length} shared files waiting to be uploaded.", + // action: SnackBarActionConfig( + // label: "Show me", + // onPressed: () { + // UploadQueueRoute().push(context); + // }, + // ), + // ); + // // showDialog( + // // context: context, + // // builder: (context) => AlertDialog( + // // title: Text("Pending files"), + // // content: Text( + // // "You have ${files.length} files waiting to be uploaded.", + // // ), + // // actions: [ + // // TextButton( + // // child: Text(S.of(context)!.gotIt), + // // onPressed: () { + // // Navigator.pop(context); + // // UploadQueueRoute().push(context); + // // }, + // // ), + // // ], + // // ), + // // ); + // } + // }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + context.read().addListener(_onTasksChanged); + } + + void _onTasksChanged() { + final taskNotifier = context.read(); + for (var task in taskNotifier.value.values) { + context.read().notifyTaskChanged(task); + } + } + + void _onReceiveSharedFiles(List sharedFiles) async { + final files = sharedFiles.map((file) => File(file.path)).toList(); + + if (files.isNotEmpty) { + final userId = context.read().id; + final notifier = context.read(); + await notifier.addFiles( + files: files, + userId: userId, + ); + final localFiles = notifier.pendingFiles; + for (int i = 0; i < localFiles.length; i++) { + final file = localFiles[i]; + await consumeLocalFile( + context, + file: file, + userId: userId, + exitAppAfterConsumed: i == localFiles.length - 1, + ); + } + } + } + + @override + void dispose() { + _subscription?.cancel(); + context.read().removeListener(_onTasksChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +Future consumeLocalFile( + BuildContext context, { + required File file, + required String userId, + bool exitAppAfterConsumed = false, +}) async { + final consumptionNotifier = context.read(); + final taskNotifier = context.read(); + final ioFile = File(file.path); + // if (!await ioFile.exists()) { + // Fluttertoast.showToast( + // msg: S.of(context)!.couldNotAccessReceivedFile, + // toastLength: Toast.LENGTH_LONG, + // ); + // } + + final bytes = ioFile.readAsBytes(); + final shouldDirectlyUpload = + Hive.globalSettingsBox.getValue()!.skipDocumentPreprarationOnUpload; + if (shouldDirectlyUpload) { + final taskId = await context.read().create( + await bytes, + filename: p.basename(file.path), + title: p.basenameWithoutExtension(file.path), + ); + consumptionNotifier.discardFile(file, userId: userId); + if (taskId != null) { + taskNotifier.listenToTaskChanges(taskId); + } + } else { + final result = await DocumentUploadRoute( + $extra: bytes, + filename: p.basenameWithoutExtension(file.path), + title: p.basenameWithoutExtension(file.path), + fileExtension: p.extension(file.path), + ).push(context) ?? + DocumentUploadResult(false, null); + + if (result.success) { + await Fluttertoast.showToast( + msg: S.of(context)!.documentSuccessfullyUploadedProcessing, + ); + await consumptionNotifier.discardFile(file, userId: userId); + + if (result.taskId != null) { + taskNotifier.listenToTaskChanges(result.taskId!); + } + if (exitAppAfterConsumed) { + SystemNavigator.pop(); + } + } else { + final shouldDiscard = await showDialog( + context: context, + builder: (context) => DiscardSharedFileDialog(bytes: bytes), + ) ?? + false; + if (shouldDiscard) { + await context + .read() + .discardFile(file, userId: userId); + } + } + } +} diff --git a/lib/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart index 66d0a870..25dbcd43 100644 --- a/lib/features/tasks/cubit/task_status_cubit.dart +++ b/lib/features/tasks/cubit/task_status_cubit.dart @@ -1,36 +1,26 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; -part 'task_status_state.dart'; -class TaskStatusCubit extends Cubit { +class PendingTasksNotifier extends ValueNotifier> { final PaperlessTasksApi _api; - TaskStatusCubit(this._api) : super(const TaskStatusState()); + PendingTasksNotifier(this._api) : super({}); void listenToTaskChanges(String taskId) { - _api - .listenForTaskChanges(taskId) - .forEach( - (element) => emit( - TaskStatusState( - isListening: true, - task: element, - ), - ), - ) - .whenComplete(() => emit(state.copyWith(isListening: false))); + _api.listenForTaskChanges(taskId).forEach((task) { + value = {...value, taskId: task}; + notifyListeners(); + }).whenComplete( + () { + value = value..remove(taskId); + notifyListeners(); + }, + ); } - Future acknowledgeCurrentTask() async { - if (state.task == null) { - return; - } - final task = await _api.acknowledgeTask(state.task!); - emit( - state.copyWith( - task: task, - isListening: false, - ), - ); + Future acknowledgeTasks(Iterable taskIds) async { + final tasks = value.values.where((task) => taskIds.contains(task.taskId)); + await Future.wait([for (var task in tasks) _api.acknowledgeTask(task)]); + value = value..removeWhere((key, value) => taskIds.contains(key)); + notifyListeners(); } } diff --git a/lib/features/tasks/cubit/task_status_state.dart b/lib/features/tasks/cubit/task_status_state.dart deleted file mode 100644 index 163d3db5..00000000 --- a/lib/features/tasks/cubit/task_status_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -part of 'task_status_cubit.dart'; - -class TaskStatusState extends Equatable { - final Task? task; - final bool isListening; - - const TaskStatusState({ - this.task, - this.isListening = false, - }); - - bool get isSuccess => task?.status == TaskStatus.success; - - bool get isAcknowledged => task?.acknowledged ?? false; - - String? get taskId => task?.taskId; - - @override - List get props => [task, isListening]; - - TaskStatusState copyWith({ - Task? task, - bool? isListening, - bool? isAcknowledged, - }) { - return TaskStatusState( - task: task ?? this.task, - isListening: isListening ?? this.isListening, - ); - } -} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index b631de0c..e170bb7d 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -865,11 +865,11 @@ "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." }, - "editView": "Edit View", + "editView": "Editar Vista", "@editView": { "description": "Title of the edit saved view page" }, - "donate": "Donate", + "donate": "Donar", "@donate": { "description": "Label of the in-app donate button" }, @@ -877,23 +877,23 @@ "@donationDialogContent": { "description": "Text displayed in the donation dialog" }, - "noDocumentsFound": "No documents found.", + "noDocumentsFound": "Sense Documents trobats.", "@noDocumentsFound": { "description": "Message shown when no documents were found." }, - "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "couldNotDeleteCorrespondent": "No es pot esborrar corresponsal, torna a provar.", "@couldNotDeleteCorrespondent": { "description": "Message shown in snackbar when a correspondent could not be deleted." }, - "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "couldNotDeleteDocumentType": "No es pot esborrar document, prova de nou.", "@couldNotDeleteDocumentType": { "description": "Message shown when a document type could not be deleted" }, - "couldNotDeleteTag": "Could not delete tag, please try again.", + "couldNotDeleteTag": "No es pot esborrar etiqueta, prova de nou.", "@couldNotDeleteTag": { "description": "Message shown when a tag could not be deleted" }, - "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "couldNotDeleteStoragePath": "No es pot esborrar ruta emmagatzematge, prova de nou.", "@couldNotDeleteStoragePath": { "description": "Message shown when a storage path could not be deleted" }, @@ -938,7 +938,7 @@ "@savedViewSuccessfullyUpdated": { "description": "Message shown when a saved view was successfully updated." }, - "discardChanges": "Discard changes?", + "discardChanges": "Descartar canvis?", "@discardChanges": { "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." }, @@ -950,11 +950,11 @@ "@createFromCurrentFilter": { "description": "Tooltip of the \"New saved view\" button" }, - "home": "Home", + "home": "Inici", "@home": { "description": "Label of the \"Home\" route" }, - "welcomeUser": "Welcome, {name}!", + "welcomeUser": "Benvingut {name}!", "@welcomeUser": { "description": "Top message shown on the home page" }, @@ -962,16 +962,23 @@ "@noSavedViewOnHomepageHint": { "description": "Message shown when there is no saved view to display on the home page." }, - "statistics": "Statistics", - "documentsInInbox": "Documents in inbox", + "statistics": "Estadí­stiques", + "documentsInInbox": "Document safata", "totalDocuments": "Total documents", - "totalCharacters": "Total characters", - "showAll": "Show all", + "totalCharacters": "Caràcters Totals", + "showAll": "Mostra tot", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" }, - "userAlreadyExists": "This user already exists.", + "userAlreadyExists": "Usuari ja esisteix.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 82f70c3c..015140ae 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 8a317111..97f090aa 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "Dieser Nutzer existiert bereits.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "Du hast noch keine Ansichten gespeichert. Erstelle eine neue Ansicht, und sie wird hier angezeigt.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Erneut versuchen", + "discardFile": "Datei verwerfen?", + "discard": "Verwerfen" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1e008546..db1778bf 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 6096a3fd..3e0224f9 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 308916e4..778733d0 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index cf000d60..9153d4c2 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 8d82ccfc..f89e99de 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -5,9 +5,9 @@ "name": {} } }, - "addAnotherAccount": "Добавить другую учетную запись", + "addAnotherAccount": "Добавить другой аккаунт", "@addAnotherAccount": {}, - "account": "Учётная запись", + "account": "Аккаунт", "@account": {}, "addCorrespondent": "Новый корреспондент", "@addCorrespondent": { @@ -29,949 +29,956 @@ "@aboutThisApp": { "description": "Label for about this app tile displayed in the drawer" }, - "loggedInAs": "Logged in as {name}", + "loggedInAs": "Вход выполнен как {name}", "@loggedInAs": { "placeholders": { "name": {} } }, - "disconnect": "Disconnect", + "disconnect": "Отключиться", "@disconnect": { "description": "Logout button label" }, - "reportABug": "Report a Bug", + "reportABug": "Сообщить об ошибке", "@reportABug": {}, - "settings": "Settings", + "settings": "Настройки", "@settings": {}, - "authenticateOnAppStart": "Authenticate on app start", + "authenticateOnAppStart": "Аутентифицироваться при запуске приложения", "@authenticateOnAppStart": { "description": "Description of the biometric authentication settings tile" }, - "biometricAuthentication": "Biometric authentication", + "biometricAuthentication": "Биометрическая аутентификация", "@biometricAuthentication": {}, - "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate to enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Авторизуйтесь для включения биометрической аутентификации} disable{Авторизуйтесь для отключения биометрической аутентификации} other{}}", "@authenticateToToggleBiometricAuthentication": { "placeholders": { "mode": {} } }, - "documents": "Documents", + "documents": "Документы", "@documents": {}, - "inbox": "Inbox", + "inbox": "Входящие", "@inbox": {}, - "labels": "Labels", + "labels": "Метки", "@labels": {}, - "scanner": "Scanner", + "scanner": "Сканер", "@scanner": {}, - "startTyping": "Start typing...", + "startTyping": "Начните вводить текст...", "@startTyping": {}, - "doYouReallyWantToDeleteThisView": "Do you really want to delete this view?", + "doYouReallyWantToDeleteThisView": "Вы действительно хотите удалить этот вид?", "@doYouReallyWantToDeleteThisView": {}, "deleteView": "", "@deleteView": {}, - "addedAt": "Added at", + "addedAt": "Добавлено в", "@addedAt": {}, - "archiveSerialNumber": "Archive Serial Number", + "archiveSerialNumber": "Серийный номер архива", "@archiveSerialNumber": {}, "asn": "ASN", "@asn": {}, - "correspondent": "Correspondent", + "correspondent": "Корреспондент", "@correspondent": {}, - "createdAt": "Created at", + "createdAt": "Создано в", "@createdAt": {}, - "documentSuccessfullyDeleted": "Document successfully deleted.", + "documentSuccessfullyDeleted": "Документ успешно удален.", "@documentSuccessfullyDeleted": {}, - "assignAsn": "Assign ASN", + "assignAsn": "Назначить ASN", "@assignAsn": {}, - "deleteDocumentTooltip": "Delete", + "deleteDocumentTooltip": "Удалить", "@deleteDocumentTooltip": { "description": "Tooltip shown for the delete button on details page" }, - "downloadDocumentTooltip": "Download", + "downloadDocumentTooltip": "Скачать", "@downloadDocumentTooltip": { "description": "Tooltip shown for the download button on details page" }, - "editDocumentTooltip": "Edit", + "editDocumentTooltip": "Редактировать", "@editDocumentTooltip": { "description": "Tooltip shown for the edit button on details page" }, - "loadFullContent": "Load full content", + "loadFullContent": "Загрузить полный контент", "@loadFullContent": {}, - "noAppToDisplayPDFFilesFound": "No app to display PDF files found!", + "noAppToDisplayPDFFilesFound": "Не найдено приложений для отображения PDF-файлов!", "@noAppToDisplayPDFFilesFound": {}, - "openInSystemViewer": "Open in system viewer", + "openInSystemViewer": "Открыть в системном просмотрщике", "@openInSystemViewer": {}, - "couldNotOpenFilePermissionDenied": "Could not open file: Permission denied.", + "couldNotOpenFilePermissionDenied": "Не удалось открыть файл: Отказано в разрешении.", "@couldNotOpenFilePermissionDenied": {}, - "previewTooltip": "Preview", + "previewTooltip": "Предпросмотр", "@previewTooltip": { "description": "Tooltip shown for the preview button on details page" }, - "shareTooltip": "Share", + "shareTooltip": "Поделиться", "@shareTooltip": { "description": "Tooltip shown for the share button on details page" }, - "similarDocuments": "Similar Documents", + "similarDocuments": "Похожие документы", "@similarDocuments": { "description": "Label shown in the tabbar on details page" }, - "content": "Content", + "content": "Контент", "@content": { "description": "Label shown in the tabbar on details page" }, - "metaData": "Meta Data", + "metaData": "Метаданные", "@metaData": { "description": "Label shown in the tabbar on details page" }, - "overview": "Overview", + "overview": "Обзор", "@overview": { "description": "Label shown in the tabbar on details page" }, - "documentType": "Document Type", + "documentType": "Тип документа", "@documentType": {}, - "archivedPdf": "Archived (pdf)", + "archivedPdf": "Архивировано (pdf)", "@archivedPdf": { "description": "Option to chose when downloading a document" }, - "chooseFiletype": "Choose filetype", + "chooseFiletype": "Выберите тип файла", "@chooseFiletype": {}, - "original": "Original", + "original": "Оригинал", "@original": { "description": "Option to chose when downloading a document" }, - "documentSuccessfullyDownloaded": "Document successfully downloaded.", + "documentSuccessfullyDownloaded": "Документ успешно загружен.", "@documentSuccessfullyDownloaded": {}, - "suggestions": "Suggestions: ", + "suggestions": "Предложения: ", "@suggestions": {}, - "editDocument": "Edit Document", + "editDocument": "Редактировать документ", "@editDocument": {}, - "advanced": "Advanced", + "advanced": "Дополнительно", "@advanced": {}, - "apply": "Apply", + "apply": "Применить", "@apply": {}, - "extended": "Extended", + "extended": "Расширенный", "@extended": {}, - "titleAndContent": "Title & Content", + "titleAndContent": "Название и Контент", "@titleAndContent": {}, - "title": "Title", + "title": "Название", "@title": {}, - "reset": "Reset", + "reset": "Сброс", "@reset": {}, - "filterDocuments": "Filter Documents", + "filterDocuments": "Фильтр документов", "@filterDocuments": { "description": "Title of the document filter" }, - "originalMD5Checksum": "Original MD5-Checksum", + "originalMD5Checksum": "Оригинальная MD5-контрольная сумма", "@originalMD5Checksum": {}, - "mediaFilename": "Media Filename", + "mediaFilename": "Название медиафайла", "@mediaFilename": {}, - "originalFileSize": "Original File Size", + "originalFileSize": "Оригинальный размер файла", "@originalFileSize": {}, - "originalMIMEType": "Original MIME-Type", + "originalMIMEType": "Оригинальный MIME-тип", "@originalMIMEType": {}, - "modifiedAt": "Modified at", + "modifiedAt": "Изменено в", "@modifiedAt": {}, - "preview": "Preview", + "preview": "Предпросмотр", "@preview": { "description": "Title of the document preview page" }, - "scanADocument": "Scan a document", + "scanADocument": "Сканировать документ", "@scanADocument": {}, - "noDocumentsScannedYet": "No documents scanned yet.", + "noDocumentsScannedYet": "Документы еще не сканированы.", "@noDocumentsScannedYet": {}, - "or": "or", + "or": "или", "@or": { "description": "Used on the scanner page between both main actions when no scans have been captured." }, - "deleteAllScans": "Delete all scans", + "deleteAllScans": "Удалить все сканирования", "@deleteAllScans": {}, - "uploadADocumentFromThisDevice": "Upload a document from this device", + "uploadADocumentFromThisDevice": "Загрузить документ с этого устройства", "@uploadADocumentFromThisDevice": { "description": "Button label on scanner page" }, - "noMatchesFound": "No matches found.", + "noMatchesFound": "Ничего не найдено.", "@noMatchesFound": { "description": "Displayed when no documents were found in the document search." }, - "removeFromSearchHistory": "Remove from search history?", + "removeFromSearchHistory": "Удалить из истории поиска?", "@removeFromSearchHistory": {}, - "results": "Results", + "results": "Результаты", "@results": { "description": "Label displayed above search results in document search." }, - "searchDocuments": "Search documents", + "searchDocuments": "Поиск документов", "@searchDocuments": {}, - "resetFilter": "Reset filter", + "resetFilter": "Сбросить фильтр", "@resetFilter": {}, - "lastMonth": "Last Month", + "lastMonth": "Прошлый месяц", "@lastMonth": {}, - "last7Days": "Last 7 Days", + "last7Days": "Последние 7 дней", "@last7Days": {}, - "last3Months": "Last 3 Months", + "last3Months": "Последние 3 месяца", "@last3Months": {}, - "lastYear": "Last Year", + "lastYear": "Прошлый год", "@lastYear": {}, - "search": "Search", + "search": "Поиск", "@search": {}, - "documentsSuccessfullyDeleted": "Documents successfully deleted.", + "documentsSuccessfullyDeleted": "Документ успешно удален.", "@documentsSuccessfullyDeleted": {}, - "thereSeemsToBeNothingHere": "There seems to be nothing here...", + "thereSeemsToBeNothingHere": "Похоже, здесь ничего нет...", "@thereSeemsToBeNothingHere": {}, - "oops": "Oops.", + "oops": "Упс.", "@oops": {}, - "newDocumentAvailable": "New document available!", + "newDocumentAvailable": "Доступен новый документ!", "@newDocumentAvailable": {}, - "orderBy": "Order By", + "orderBy": "Упорядочить по", "@orderBy": {}, - "thisActionIsIrreversibleDoYouWishToProceedAnyway": "This action is irreversible. Do you wish to proceed anyway?", + "thisActionIsIrreversibleDoYouWishToProceedAnyway": "Это действие необратимо. Все равно хотите продолжить?", "@thisActionIsIrreversibleDoYouWishToProceedAnyway": {}, - "confirmDeletion": "Confirm deletion", + "confirmDeletion": "Подтвердить удаление", "@confirmDeletion": {}, - "areYouSureYouWantToDeleteTheFollowingDocuments": "{count, plural, one{Are you sure you want to delete the following document?} other{Are you sure you want to delete the following documents?}}", + "areYouSureYouWantToDeleteTheFollowingDocuments": "{count, plural, one{Вы уверены, что хотите удалить следующий документ?} few {Вы уверены, что хотите удалить следующие документы?} many {Вы уверены, что хотите удалить следующие документы?} other{Вы уверены, что хотите удалить следующие документы?}}", "@areYouSureYouWantToDeleteTheFollowingDocuments": { "placeholders": { "count": {} } }, - "countSelected": "{count} selected", + "countSelected": "{count} выбрано", "@countSelected": { "description": "Displayed in the appbar when at least one document is selected.", "placeholders": { "count": {} } }, - "storagePath": "Storage Path", + "storagePath": "Путь хранения", "@storagePath": {}, - "prepareDocument": "Prepare document", + "prepareDocument": "Подготовить документ", "@prepareDocument": {}, - "tags": "Tags", + "tags": "Теги", "@tags": {}, - "documentSuccessfullyUpdated": "Document successfully updated.", + "documentSuccessfullyUpdated": "Документ успешно обновлен.", "@documentSuccessfullyUpdated": {}, - "fileName": "File Name", + "fileName": "Имя файла", "@fileName": {}, - "synchronizeTitleAndFilename": "Synchronize title and filename", + "synchronizeTitleAndFilename": "Синхронизировать название и имя файла", "@synchronizeTitleAndFilename": {}, - "reload": "Reload", + "reload": "Перезагрузить", "@reload": {}, - "documentSuccessfullyUploadedProcessing": "Document successfully uploaded, processing...", + "documentSuccessfullyUploadedProcessing": "Документ успешно загружен, обработка...", "@documentSuccessfullyUploadedProcessing": {}, - "deleteLabelWarningText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?", + "deleteLabelWarningText": "Эта метка содержит ссылки на другие документы. Удаляя эту метку, все ссылки будут удалены. Продолжить?", "@deleteLabelWarningText": {}, - "couldNotAcknowledgeTasks": "Could not acknowledge tasks.", + "couldNotAcknowledgeTasks": "Не удалось подтвердить задания.", "@couldNotAcknowledgeTasks": {}, - "authenticationFailedPleaseTryAgain": "Authentication failed, please try again.", + "authenticationFailedPleaseTryAgain": "Аутентификация не удалась, попробуйте еще раз.", "@authenticationFailedPleaseTryAgain": {}, - "anErrorOccurredWhileTryingToAutocompleteYourQuery": "An error ocurred while trying to autocomplete your query.", + "anErrorOccurredWhileTryingToAutocompleteYourQuery": "Произошла ошибка при попытке автоматического заполнения запроса.", "@anErrorOccurredWhileTryingToAutocompleteYourQuery": {}, - "biometricAuthenticationFailed": "Biometric authentication failed.", + "biometricAuthenticationFailed": "Биометрическая аутентификация провалена.", "@biometricAuthenticationFailed": {}, - "biometricAuthenticationNotSupported": "Biometric authentication not supported on this device.", + "biometricAuthenticationNotSupported": "Биометрическая аутентификация не поддерживается на этом устройстве.", "@biometricAuthenticationNotSupported": {}, - "couldNotBulkEditDocuments": "Could not bulk edit documents.", + "couldNotBulkEditDocuments": "Не удалось редактировать документы.", "@couldNotBulkEditDocuments": {}, - "couldNotCreateCorrespondent": "Could not create correspondent, please try again.", + "couldNotCreateCorrespondent": "Не удалось создать корреспондента, попробуйте еще раз.", "@couldNotCreateCorrespondent": {}, - "couldNotLoadCorrespondents": "Could not load correspondents.", + "couldNotLoadCorrespondents": "Не удалось загрузить корреспондентов.", "@couldNotLoadCorrespondents": {}, - "couldNotCreateSavedView": "Could not create saved view, please try again.", + "couldNotCreateSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз.", "@couldNotCreateSavedView": {}, - "couldNotDeleteSavedView": "Could not delete saved view, please try again", + "couldNotDeleteSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз", "@couldNotDeleteSavedView": {}, - "youAreCurrentlyOffline": "You are currently offline. Please make sure you are connected to the internet.", + "youAreCurrentlyOffline": "В настоящее время вы не в сети. Убедитесь, что вы подключены к Интернету.", "@youAreCurrentlyOffline": {}, - "couldNotAssignArchiveSerialNumber": "Could not assign archive serial number.", + "couldNotAssignArchiveSerialNumber": "Не удалось присвоить архивный серийный номер.", "@couldNotAssignArchiveSerialNumber": {}, - "couldNotDeleteDocument": "Could not delete document, please try again.", + "couldNotDeleteDocument": "Не удалось удалить документ, попробуйте еще раз.", "@couldNotDeleteDocument": {}, - "couldNotLoadDocuments": "Could not load documents, please try again.", + "couldNotLoadDocuments": "Не удалось загрузить документы, попробуйте еще раз.", "@couldNotLoadDocuments": {}, - "couldNotLoadDocumentPreview": "Could not load document preview.", + "couldNotLoadDocumentPreview": "Не удалось загрузить предпросмотр документа.", "@couldNotLoadDocumentPreview": {}, - "couldNotCreateDocument": "Could not create document, please try again.", + "couldNotCreateDocument": "Не удалось создать документ, попробуйте еще раз.", "@couldNotCreateDocument": {}, - "couldNotLoadDocumentTypes": "Could not load document types, please try again.", + "couldNotLoadDocumentTypes": "Не удалось загрузить типы документов, попробуйте еще раз.", "@couldNotLoadDocumentTypes": {}, - "couldNotUpdateDocument": "Could not update document, please try again.", + "couldNotUpdateDocument": "Не удалось обновить документ, попробуйте еще раз.", "@couldNotUpdateDocument": {}, - "couldNotUploadDocument": "Could not upload document, please try again.", + "couldNotUploadDocument": "Не удалось загрузить документ, попробуйте еще раз.", "@couldNotUploadDocument": {}, - "invalidCertificateOrMissingPassphrase": "Invalid certificate or missing passphrase, please try again", + "invalidCertificateOrMissingPassphrase": "Неверный сертификат или отсутствующая ключевая фраза, пожалуйста попробуйте еще раз", "@invalidCertificateOrMissingPassphrase": {}, - "couldNotLoadSavedViews": "Could not load saved views.", + "couldNotLoadSavedViews": "Не удалось загрузить сохраненные виды.", "@couldNotLoadSavedViews": {}, - "aClientCertificateWasExpectedButNotSent": "A client certificate was expected but not sent. Please provide a valid client certificate.", + "aClientCertificateWasExpectedButNotSent": "Ожидался сертификат клиента, но он не отправлен. Пожалуйста, предоставьте действительный клиентский сертификат.", "@aClientCertificateWasExpectedButNotSent": {}, - "userIsNotAuthenticated": "User is not authenticated.", + "userIsNotAuthenticated": "Пользователь не аутентифицирован.", "@userIsNotAuthenticated": {}, - "requestTimedOut": "The request to the server timed out.", + "requestTimedOut": "Время ожидания запроса на сервер истекло.", "@requestTimedOut": {}, - "anErrorOccurredRemovingTheScans": "An error occurred removing the scans.", + "anErrorOccurredRemovingTheScans": "Произошла ошибка при удалении сканирования.", "@anErrorOccurredRemovingTheScans": {}, - "couldNotReachYourPaperlessServer": "Could not reach your Paperless server, is it up and running?", + "couldNotReachYourPaperlessServer": "Не удалось получить доступ к вашему серверу Paperless, он работает?", "@couldNotReachYourPaperlessServer": {}, - "couldNotLoadSimilarDocuments": "Could not load similar documents.", + "couldNotLoadSimilarDocuments": "Не удалось загрузить похожие документы.", "@couldNotLoadSimilarDocuments": {}, - "couldNotCreateStoragePath": "Could not create storage path, please try again.", + "couldNotCreateStoragePath": "Не удалось создать путь к хранилищу, пожалуйста, попробуйте еще раз.", "@couldNotCreateStoragePath": {}, - "couldNotLoadStoragePaths": "Could not load storage paths.", + "couldNotLoadStoragePaths": "Не удалось загрузить пути хранилища.", "@couldNotLoadStoragePaths": {}, - "couldNotLoadSuggestions": "Could not load suggestions.", + "couldNotLoadSuggestions": "Не удалось загрузить предложения.", "@couldNotLoadSuggestions": {}, - "couldNotCreateTag": "Could not create tag, please try again.", + "couldNotCreateTag": "Не удалось создать тег, попробуйте еще раз.", "@couldNotCreateTag": {}, - "couldNotLoadTags": "Could not load tags.", + "couldNotLoadTags": "Не удалось загрузить теги.", "@couldNotLoadTags": {}, - "anUnknownErrorOccurred": "An unknown error occurred.", + "anUnknownErrorOccurred": "Произошла неизвестная ошибка.", "@anUnknownErrorOccurred": {}, - "fileFormatNotSupported": "This file format is not supported.", + "fileFormatNotSupported": "Этот формат файла не поддерживается.", "@fileFormatNotSupported": {}, - "report": "REPORT", + "report": "СООБЩИТЬ", "@report": {}, - "absolute": "Absolute", + "absolute": "Абсолютный", "@absolute": {}, - "hintYouCanAlsoSpecifyRelativeValues": "Hint: Apart from concrete dates, you can also specify a time range relative to the current date.", + "hintYouCanAlsoSpecifyRelativeValues": "Подсказка: Помимо конкретных дат, вы можете также указать диапазон времени относительно текущей даты.", "@hintYouCanAlsoSpecifyRelativeValues": { "description": "Displayed in the extended date range picker" }, - "amount": "Amount", + "amount": "Количество", "@amount": {}, - "relative": "Relative", + "relative": "Относительно", "@relative": {}, - "last": "Last", + "last": "Последний", "@last": {}, - "timeUnit": "Time unit", + "timeUnit": "Единица времени", "@timeUnit": {}, - "selectDateRange": "Select date range", + "selectDateRange": "Выберите диапазон дат", "@selectDateRange": {}, - "after": "After", + "after": "После", "@after": {}, - "before": "Before", + "before": "Ранее", "@before": {}, - "days": "{count, plural, zero{days} one{day} other{days}}", + "days": "{count, plural, one{день} few {дней} many {дней} other{дней}}", "@days": { "placeholders": { "count": {} } }, - "lastNDays": "{count, plural, zero{} one{Yesterday} other{Last {count} days}}", + "lastNDays": "{count, plural, one{Вчера} few {Прошло {count} дней} many {Прошло {count} дней} other{Прошло {count} дней}}", "@lastNDays": { "placeholders": { "count": {} } }, - "lastNMonths": "{count, plural, zero{} one{Last month} other{Last {count} months}}", + "lastNMonths": "{count, plural, one{В прошлом месяце} few {Прошло {count} месяцев} many {Прошло {count} месяцев} other{Прошло {count} месяцев}}", "@lastNMonths": { "placeholders": { "count": {} } }, - "lastNWeeks": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}", + "lastNWeeks": "{count, plural, one{На прошлой неделе} few {Прошло {count} недели} many {Прошло {count} недели} other{Прошло {count} недели}}", "@lastNWeeks": { "placeholders": { "count": {} } }, - "lastNYears": "{count, plural, zero{} one{Last year} other{Last {count} years}}", + "lastNYears": "{count, plural, one{В прошлом году} few {Прошло {count} года} many {Прошло {count} года} other{Прошло {count} года}}", "@lastNYears": { "placeholders": { "count": {} } }, - "months": "{count, plural, zero{} one{month} other{months}}", + "months": "{count, plural, one{месяц} few {месяцы} many {месяцы} other{месяцы}}", "@months": { "placeholders": { "count": {} } }, - "weeks": "{count, plural, zero{} one{week} other{weeks}}", + "weeks": "{count, plural, one{неделя} few {недели} many {недели} other{недели}}", "@weeks": { "placeholders": { "count": {} } }, - "years": "{count, plural, zero{} one{year} other{years}}", + "years": "{count, plural, one{год} few {года} many {года} other{года}}", "@years": { "placeholders": { "count": {} } }, - "gotIt": "Got it!", + "gotIt": "Понял!", "@gotIt": {}, - "cancel": "Cancel", + "cancel": "Отменить", "@cancel": {}, - "close": "Close", + "close": "Закрыть", "@close": {}, - "create": "Create", + "create": "Создать", "@create": {}, - "delete": "Delete", + "delete": "Удалить", "@delete": {}, - "edit": "Edit", + "edit": "Редактировать", "@edit": {}, - "ok": "Ok", + "ok": "Ок", "@ok": {}, - "save": "Save", + "save": "Сохранить", "@save": {}, - "select": "Select", + "select": "Выбрать", "@select": {}, - "saveChanges": "Save changes", + "saveChanges": "Сохранить изменения", "@saveChanges": {}, - "upload": "Upload", + "upload": "Загрузить", "@upload": {}, - "youreOffline": "You're offline.", + "youreOffline": "Вы не в сети.", "@youreOffline": {}, - "deleteDocument": "Delete document", + "deleteDocument": "Удалить документ", "@deleteDocument": { "description": "Used as an action label on each inbox item" }, - "removeDocumentFromInbox": "Document removed from inbox.", + "removeDocumentFromInbox": "Документ удален из \"Входящие\".", "@removeDocumentFromInbox": {}, - "areYouSureYouWantToMarkAllDocumentsAsSeen": "Are you sure you want to mark all documents as seen? This will perform a bulk edit operation removing all inbox tags from the documents. This action is not reversible! Are you sure you want to continue?", + "areYouSureYouWantToMarkAllDocumentsAsSeen": "Вы уверены, что хотите отметить все документы как просмотренные? Это выполнит операцию массового редактирования, удалив все входящие теги из документов. Это действие необратимо! Вы уверены, что хотите продолжить?", "@areYouSureYouWantToMarkAllDocumentsAsSeen": {}, - "markAllAsSeen": "Mark all as seen?", + "markAllAsSeen": "Отметить все как просмотренные?", "@markAllAsSeen": {}, - "allSeen": "All seen", + "allSeen": "Все просмотренные", "@allSeen": {}, - "markAsSeen": "Mark as seen", + "markAsSeen": "Отметить все как просмотренные", "@markAsSeen": {}, - "refresh": "Refresh", + "refresh": "Обновить", "@refresh": {}, - "youDoNotHaveUnseenDocuments": "You do not have unseen documents.", + "youDoNotHaveUnseenDocuments": "У вас нет непросмотренных документов.", "@youDoNotHaveUnseenDocuments": {}, - "quickAction": "Quick Action", + "quickAction": "Быстрое действие", "@quickAction": {}, - "suggestionSuccessfullyApplied": "Suggestion successfully applied.", + "suggestionSuccessfullyApplied": "Предложение успешно применено.", "@suggestionSuccessfullyApplied": {}, - "today": "Today", + "today": "Сегодня", "@today": {}, - "undo": "Undo", + "undo": "Отменить", "@undo": {}, - "nUnseen": "{count} unseen", + "nUnseen": "{count} непросмотренных", "@nUnseen": { "placeholders": { "count": {} } }, - "swipeLeftToMarkADocumentAsSeen": "Hint: Swipe left to mark a document as seen and remove all inbox tags from the document.", + "swipeLeftToMarkADocumentAsSeen": "Подсказка: Проведите пальцем влево, чтобы отметить документ как просмотренный и удалить все входящие теги из документа.", "@swipeLeftToMarkADocumentAsSeen": {}, - "yesterday": "Yesterday", + "yesterday": "Вчера", "@yesterday": {}, - "anyAssigned": "Any assigned", + "anyAssigned": "Любые назначенные", "@anyAssigned": {}, - "noItemsFound": "No items found!", + "noItemsFound": "Элементы не найдены!", "@noItemsFound": {}, - "caseIrrelevant": "Case Irrelevant", + "caseIrrelevant": "Дело не имеет отношение", "@caseIrrelevant": {}, - "matchingAlgorithm": "Matching Algorithm", + "matchingAlgorithm": "Алгоритм подбора", "@matchingAlgorithm": {}, - "match": "Match", + "match": "Соответствует", "@match": {}, - "name": "Name", + "name": "Имя", "@name": {}, - "notAssigned": "Not assigned", + "notAssigned": "Не назначенные", "@notAssigned": {}, - "addNewCorrespondent": "Add new correspondent", + "addNewCorrespondent": "Добавить нового корреспондента", "@addNewCorrespondent": {}, - "noCorrespondentsSetUp": "You don't seem to have any correspondents set up.", + "noCorrespondentsSetUp": "Похоже, у вас нет настроенных корреспондентов.", "@noCorrespondentsSetUp": {}, - "correspondents": "Correspondents", + "correspondents": "Корреспонденты", "@correspondents": {}, - "addNewDocumentType": "Add new document type", + "addNewDocumentType": "Добавить новый тип документа", "@addNewDocumentType": {}, - "noDocumentTypesSetUp": "You don't seem to have any document types set up.", + "noDocumentTypesSetUp": "Похоже, у вас нет каких-либо типов документов.", "@noDocumentTypesSetUp": {}, - "documentTypes": "Document Types", + "documentTypes": "Типы документов", "@documentTypes": {}, - "addNewStoragePath": "Add new storage path", + "addNewStoragePath": "Добавить новый путь к хранилищу", "@addNewStoragePath": {}, - "noStoragePathsSetUp": "You don't seem to have any storage paths set up.", + "noStoragePathsSetUp": "Похоже, у вас нет никаких путей хранения.", "@noStoragePathsSetUp": {}, - "storagePaths": "Storage Paths", + "storagePaths": "Пути хранения", "@storagePaths": {}, - "addNewTag": "Add new tag", + "addNewTag": "Добавить новый тег", "@addNewTag": {}, - "noTagsSetUp": "You don't seem to have any tags set up.", + "noTagsSetUp": "Похоже, у вас нет настроенных тегов.", "@noTagsSetUp": {}, - "linkedDocuments": "Linked Documents", + "linkedDocuments": "Связанные документы", "@linkedDocuments": {}, - "advancedSettings": "Advanced Settings", + "advancedSettings": "Дополнительные настройки", "@advancedSettings": {}, - "passphrase": "Passphrase", + "passphrase": "Ключевая фраза", "@passphrase": {}, - "configureMutualTLSAuthentication": "Configure Mutual TLS Authentication", + "configureMutualTLSAuthentication": "Настроить взаимную TLS аутентификацию", "@configureMutualTLSAuthentication": {}, - "invalidCertificateFormat": "Invalid certificate format, only .pfx is allowed", + "invalidCertificateFormat": "Неверный формат сертификата, разрешено только .pfx", "@invalidCertificateFormat": {}, - "clientcertificate": "Client Certificate", + "clientcertificate": "Сертификат клиента", "@clientcertificate": {}, - "selectFile": "Select file...", + "selectFile": "Выберите файл...", "@selectFile": {}, - "continueLabel": "Continue", + "continueLabel": "Продолжить", "@continueLabel": {}, - "incorrectOrMissingCertificatePassphrase": "Incorrect or missing certificate passphrase.", + "incorrectOrMissingCertificatePassphrase": "Неверный или отсутствует пароль сертификата.", "@incorrectOrMissingCertificatePassphrase": {}, - "connect": "Connect", + "connect": "Подключиться", "@connect": {}, - "password": "Password", + "password": "Пароль", "@password": {}, - "passwordMustNotBeEmpty": "Password must not be empty.", + "passwordMustNotBeEmpty": "Пароль не может быть пустым.", "@passwordMustNotBeEmpty": {}, - "connectionTimedOut": "Connection timed out.", + "connectionTimedOut": "Время ожидания истекло.", "@connectionTimedOut": {}, - "loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.", + "loginPageReachabilityMissingClientCertificateText": "Ожидался сертификат клиента, но он не отправлен. Пожалуйста, предоставьте сертификат.", "@loginPageReachabilityMissingClientCertificateText": {}, - "couldNotEstablishConnectionToTheServer": "Could not establish a connection to the server.", + "couldNotEstablishConnectionToTheServer": "Не удалось установить соединение с сервером.", "@couldNotEstablishConnectionToTheServer": {}, - "connectionSuccessfulylEstablished": "Connection successfully established.", + "connectionSuccessfulylEstablished": "Соединение успешно установлено.", "@connectionSuccessfulylEstablished": {}, - "hostCouldNotBeResolved": "Host could not be resolved. Please check the server address and your internet connection. ", + "hostCouldNotBeResolved": "Хост не может быть решен. Пожалуйста, проверьте адрес сервера и подключение к Интернету. ", "@hostCouldNotBeResolved": {}, - "serverAddress": "Server Address", + "serverAddress": "Адрес сервера", "@serverAddress": {}, - "invalidAddress": "Invalid address.", + "invalidAddress": "Неверный адрес.", "@invalidAddress": {}, - "serverAddressMustIncludeAScheme": "Server address must include a scheme.", + "serverAddressMustIncludeAScheme": "Адрес сервера должен включать схему.", "@serverAddressMustIncludeAScheme": {}, - "serverAddressMustNotBeEmpty": "Server address must not be empty.", + "serverAddressMustNotBeEmpty": "Адрес сервера не должен быть пустым.", "@serverAddressMustNotBeEmpty": {}, - "signIn": "Sign In", + "signIn": "Войти", "@signIn": {}, - "loginPageSignInTitle": "Sign In", + "loginPageSignInTitle": "Войти", "@loginPageSignInTitle": {}, - "signInToServer": "Sign in to {serverAddress}", + "signInToServer": "Войти в {serverAddress}", "@signInToServer": { "placeholders": { "serverAddress": {} } }, - "connectToPaperless": "Connect to Paperless", + "connectToPaperless": "Подключение к Paperless", "@connectToPaperless": {}, - "username": "Username", + "username": "Имя пользователя", "@username": {}, - "usernameMustNotBeEmpty": "Username must not be empty.", + "usernameMustNotBeEmpty": "Имя пользователя не должно быть пустым.", "@usernameMustNotBeEmpty": {}, - "documentContainsAllOfTheseWords": "Document contains all of these words", + "documentContainsAllOfTheseWords": "Документ содержит все эти слова", "@documentContainsAllOfTheseWords": {}, - "all": "All", + "all": "Все", "@all": {}, - "documentContainsAnyOfTheseWords": "Document contains any of these words", + "documentContainsAnyOfTheseWords": "Документ содержит любое из этих слов", "@documentContainsAnyOfTheseWords": {}, - "any": "Any", + "any": "Любые", "@any": {}, - "learnMatchingAutomatically": "Learn matching automatically", + "learnMatchingAutomatically": "Научиться подбирать автоматически", "@learnMatchingAutomatically": {}, - "auto": "Auto", + "auto": "Авто", "@auto": {}, - "documentContainsThisString": "Document contains this string", + "documentContainsThisString": "Документ содержит эту строку", "@documentContainsThisString": {}, - "exact": "Exact", + "exact": "Точно", "@exact": {}, - "documentContainsAWordSimilarToThisWord": "Document contains a word similar to this word", + "documentContainsAWordSimilarToThisWord": "Документ содержит слово, похожее на это слово", "@documentContainsAWordSimilarToThisWord": {}, - "fuzzy": "Fuzzy", + "fuzzy": "Неточно", "@fuzzy": {}, - "documentMatchesThisRegularExpression": "Document matches this regular expression", + "documentMatchesThisRegularExpression": "Документ соответствует этому регулярному выражению", "@documentMatchesThisRegularExpression": {}, - "regularExpression": "Regular Expression", + "regularExpression": "Регулярное выражение", "@regularExpression": {}, - "anInternetConnectionCouldNotBeEstablished": "An internet connection could not be established.", + "anInternetConnectionCouldNotBeEstablished": "Не удалось установить соединение с интернетом.", "@anInternetConnectionCouldNotBeEstablished": {}, - "done": "Done", + "done": "Готово", "@done": {}, - "next": "Next", + "next": "Следующее", "@next": {}, - "couldNotAccessReceivedFile": "Could not access the received file. Please try to open the app before sharing.", + "couldNotAccessReceivedFile": "Не удалось получить доступ к полученному файлу. Пожалуйста, попробуйте открыть приложение перед тем, как поделиться.", "@couldNotAccessReceivedFile": {}, - "newView": "New View", + "newView": "Новый вид", "@newView": {}, - "createsASavedViewBasedOnTheCurrentFilterCriteria": "Creates a new view based on the current filter criteria.", + "createsASavedViewBasedOnTheCurrentFilterCriteria": "Создает новый вид на основе текущих критериев фильтра.", "@createsASavedViewBasedOnTheCurrentFilterCriteria": {}, - "createViewsToQuicklyFilterYourDocuments": "Create views to quickly filter your documents.", + "createViewsToQuicklyFilterYourDocuments": "Создавайте виды для быстрой фильтрации документов.", "@createViewsToQuicklyFilterYourDocuments": {}, - "nFiltersSet": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "nFiltersSet": "{count, plural, one{{count} фильтр установлен} few {{count} фильтров установлено} many {{count} фильтров установлено} other{{count} фильтров установлено}}", "@nFiltersSet": { "placeholders": { "count": {} } }, - "showInSidebar": "Show in sidebar", + "showInSidebar": "Показать в боковой панели", "@showInSidebar": {}, - "showOnDashboard": "Show on dashboard", + "showOnDashboard": "Показать в панели управления", "@showOnDashboard": {}, - "views": "Views", + "views": "Виды", "@views": {}, - "clearAll": "Clear all", + "clearAll": "Очистить все", "@clearAll": {}, - "scan": "Scan", + "scan": "Сканировать", "@scan": {}, - "previewScan": "Preview", + "previewScan": "Предпросмотр", "@previewScan": {}, - "scrollToTop": "Scroll to top", + "scrollToTop": "Прокрутить к началу", "@scrollToTop": {}, - "paperlessServerVersion": "Paperless server version", + "paperlessServerVersion": "Версия сервера Paperless", "@paperlessServerVersion": {}, - "darkTheme": "Dark Theme", + "darkTheme": "Темная тема", "@darkTheme": {}, - "lightTheme": "Light Theme", + "lightTheme": "Светлая тема", "@lightTheme": {}, - "systemTheme": "Use system theme", + "systemTheme": "Использовать системную тему", "@systemTheme": {}, - "appearance": "Appearance", + "appearance": "Оформление", "@appearance": {}, - "languageAndVisualAppearance": "Language and visual appearance", + "languageAndVisualAppearance": "Язык и визуальный вид", "@languageAndVisualAppearance": {}, - "applicationSettings": "Application", + "applicationSettings": "Применение", "@applicationSettings": {}, - "colorSchemeHint": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "colorSchemeHint": "Выберите между классической цветовой схемой, вдохновленной традиционным зеленым Paperless или используйте динамическую цветовую схему на основе вашей системной темы.", "@colorSchemeHint": {}, - "colorSchemeNotSupportedWarning": "Dynamic theming is only supported for devices running Android 12 and above. Selecting the 'Dynamic' option might not have any effect depending on your OS implementation.", + "colorSchemeNotSupportedWarning": "Динамическая тема поддерживается только для устройств с Android 12 и выше. Выбор параметра \"Динамическая\" может не повлиять на реализацию вашей ОС.", "@colorSchemeNotSupportedWarning": {}, - "colors": "Colors", + "colors": "Цвета", "@colors": {}, - "language": "Language", + "language": "Язык", "@language": {}, - "security": "Security", + "security": "Безопасность", "@security": {}, - "mangeFilesAndStorageSpace": "Manage files and storage space", + "mangeFilesAndStorageSpace": "Управлять файлами и пространством памяти", "@mangeFilesAndStorageSpace": {}, - "storage": "Storage", + "storage": "Хранилище", "@storage": {}, - "dark": "Dark", + "dark": "Темная", "@dark": {}, - "light": "Light", + "light": "Светлая", "@light": {}, - "system": "System", + "system": "Системная", "@system": {}, - "ascending": "Ascending", + "ascending": "Возрастание", "@ascending": {}, - "descending": "Descending", + "descending": "Убыванию", "@descending": {}, - "storagePathDay": "day", + "storagePathDay": "день", "@storagePathDay": {}, - "storagePathMonth": "month", + "storagePathMonth": "месяц", "@storagePathMonth": {}, - "storagePathYear": "year", + "storagePathYear": "год", "@storagePathYear": {}, - "color": "Color", + "color": "Цвет", "@color": {}, - "filterTags": "Filter tags...", + "filterTags": "Фильтр тегов...", "@filterTags": {}, - "inboxTag": "Inbox-Tag", + "inboxTag": "Тег \"Входящие\"", "@inboxTag": {}, - "uploadInferValuesHint": "If you specify values for these fields, your paperless instance will not automatically derive a value. If you want these values to be automatically populated by your server, leave the fields blank.", + "uploadInferValuesHint": "Если вы укажете значения для этих полей, ваш paperless экземпляр не будет автоматически получать значение. Если вы хотите, чтобы эти значения были автоматически заполнены сервером, оставьте поля пустыми.", "@uploadInferValuesHint": {}, - "useTheConfiguredBiometricFactorToAuthenticate": "Use the configured biometric factor to authenticate and unlock your documents.", + "useTheConfiguredBiometricFactorToAuthenticate": "Используйте настроенный биометрический фактор для аутентификации и разблокировки документов.", "@useTheConfiguredBiometricFactorToAuthenticate": {}, - "verifyYourIdentity": "Verify your identity", + "verifyYourIdentity": "Подтвердите вашу личность", "@verifyYourIdentity": {}, - "verifyIdentity": "Verify Identity", + "verifyIdentity": "Подтвердить личность", "@verifyIdentity": {}, - "detailed": "Detailed", + "detailed": "Подробный", "@detailed": {}, - "grid": "Grid", + "grid": "Сетка", "@grid": {}, - "list": "List", + "list": "Список", "@list": {}, - "remove": "Remove", - "removeQueryFromSearchHistory": "Remove query from search history?", - "dynamicColorScheme": "Dynamic", + "remove": "Удалить", + "removeQueryFromSearchHistory": "Удалить запрос из истории поиска?", + "dynamicColorScheme": "Динамическое", "@dynamicColorScheme": {}, - "classicColorScheme": "Classic", + "classicColorScheme": "Классическое", "@classicColorScheme": {}, - "notificationDownloadComplete": "Download complete", + "notificationDownloadComplete": "Загрузка завершена", "@notificationDownloadComplete": { "description": "Notification title when a download has been completed." }, - "notificationDownloadingDocument": "Downloading document", + "notificationDownloadingDocument": "Загружается документ", "@notificationDownloadingDocument": { "description": "Notification title shown when a document download is pending" }, - "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "archiveSerialNumberUpdated": "Архивный серийный номер обновлен.", "@archiveSerialNumberUpdated": { "description": "Message shown when the ASN has been updated." }, - "donateCoffee": "Buy me a coffee", + "donateCoffee": "Купите мне кофе", "@donateCoffee": { "description": "Label displayed in the app drawer" }, - "thisFieldIsRequired": "This field is required!", + "thisFieldIsRequired": "Требуется заполнить это поле!", "@thisFieldIsRequired": { "description": "Message shown below the form field when a required field has not been filled out." }, - "confirm": "Confirm", - "confirmAction": "Confirm action", + "confirm": "Подтвердить", + "confirmAction": "Подтвердить действие", "@confirmAction": { "description": "Typically used as a title to confirm a previously selected action" }, - "areYouSureYouWantToContinue": "Are you sure you want to continue?", - "bulkEditTagsAddMessage": "{count, plural, one{This operation will add the tags {tags} to the selected document.} other{This operation will add the tags {tags} to {count} selected documents.}}", + "areYouSureYouWantToContinue": "Вы уверены, что хотите продолжить?", + "bulkEditTagsAddMessage": "{count, plural, one{Эта операция добавит теги {tags} в выбранный документ.} other{Эта операция добавит теги {tags} в {count} выбранных документов.}}", "@bulkEditTagsAddMessage": { "description": "Message of the confirmation dialog when bulk adding tags." }, - "bulkEditTagsRemoveMessage": "{count, plural, one{This operation will remove the tags {tags} from the selected document.} other{This operation will remove the tags {tags} from {count} selected documents.}}", + "bulkEditTagsRemoveMessage": "{count, plural, one{Эта операция удалит теги {tags} из выбранного документа.} other{Эта операция удалит теги {tags} из {count} выбранных документов.}}", "@bulkEditTagsRemoveMessage": { "description": "Message of the confirmation dialog when bulk removing tags." }, - "bulkEditTagsModifyMessage": "{count, plural, one{This operation will add the tags {addTags} and remove the tags {removeTags} from the selected document.} other{This operation will add the tags {addTags} and remove the tags {removeTags} from {count} selected documents.}}", + "bulkEditTagsModifyMessage": "{count, plural, one{Данная операция добавит теги {addTags} и удалит теги {removeTags} из выбранного документа.} other{Данная операция добавит теги {addTags} и удалит теги {removeTags} из {count} выбранных документов.}}", "@bulkEditTagsModifyMessage": { "description": "Message of the confirmation dialog when both adding and removing tags." }, - "bulkEditCorrespondentAssignMessage": "{count, plural, one{This operation will assign the correspondent {correspondent} to the selected document.} other{This operation will assign the correspondent {correspondent} to {count} selected documents.}}", - "bulkEditDocumentTypeAssignMessage": "{count, plural, one{This operation will assign the document type {docType} to the selected document.} other{This operation will assign the documentType {docType} to {count} selected documents.}}", - "bulkEditStoragePathAssignMessage": "{count, plural, one{This operation will assign the storage path {path} to the selected document.} other{This operation will assign the storage path {path} to {count} selected documents.}}", - "bulkEditCorrespondentRemoveMessage": "{count, plural, one{This operation will remove the correspondent from the selected document.} other{This operation will remove the correspondent from {count} selected documents.}}", - "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{This operation will remove the document type from the selected document.} other{This operation will remove the document type from {count} selected documents.}}", - "bulkEditStoragePathRemoveMessage": "{count, plural, one{This operation will remove the storage path from the selected document.} other{This operation will remove the storage path from {count} selected documents.}}", - "anyTag": "Any", + "bulkEditCorrespondentAssignMessage": "{count, plural, one{Эта операция присвоит корреспондента {correspondent} выбранному документу.} other{Эта операция присвоит корреспондента {correspondent} {count} выбранных документов.}}", + "bulkEditDocumentTypeAssignMessage": "{count, plural, one{Эта операция присвоит тип документа {docType} выбранному документу.} other{Эта операция присвоит тип документа {docType} {count} выбранным документам.}}", + "bulkEditStoragePathAssignMessage": "{count, plural, one{Эта операция назначит путь хранения {path} выбранному документу.} other{Эта операция назначит путь хранения {path} {count} выбранным документам.}}", + "bulkEditCorrespondentRemoveMessage": "{count, plural, one{Эта операция удалит корреспондента из выбранного документа.} other{Эта операция удалит корреспондента из {count} выбранных документов.}}", + "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{Данная операция удалит тип документа из выбранного документа.} other{Данная операция удалит тип документа из {count} выбранных документов.}}", + "bulkEditStoragePathRemoveMessage": "{count, plural, one{Эта операция удалит путь хранения из выбранного документа.} other{Эта операция удалит путь хранения из {count} выбранных документов.}}", + "anyTag": "Любые", "@anyTag": { "description": "Label shown when any tag should be filtered" }, - "allTags": "All", + "allTags": "Все", "@allTags": { "description": "Label shown when a document has to be assigned to all selected tags" }, - "switchingAccountsPleaseWait": "Switching accounts. Please wait...", + "switchingAccountsPleaseWait": "Смена аккаунтов. Подождите...", "@switchingAccountsPleaseWait": { "description": "Message shown while switching accounts is in progress." }, - "testConnection": "Test connection", + "testConnection": "Проверить подключение", "@testConnection": { "description": "Button label shown on login page. Allows user to test whether the server is reachable or not." }, - "accounts": "Accounts", + "accounts": "Аккаунты", "@accounts": { "description": "Title of the account management dialog" }, - "addAccount": "Add account", + "addAccount": "Добавить аккаунт", "@addAccount": { "description": "Label of add account action" }, - "switchAccount": "Switch", + "switchAccount": "Сменить", "@switchAccount": { "description": "Label for switch account action" }, - "logout": "Logout", + "logout": "Выйти", "@logout": { "description": "Generic Logout label" }, - "switchAccountTitle": "Switch account", + "switchAccountTitle": "Сменить аккаунт", "@switchAccountTitle": { "description": "Title of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." }, - "switchToNewAccount": "Do you want to switch to the new account? You can switch back at any time.", + "switchToNewAccount": "Вы хотите переключиться на новый аккаунт? Вы можете переключиться обратно в любое время.", "@switchToNewAccount": { "description": "Content of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." }, - "sourceCode": "Source Code", - "findTheSourceCodeOn": "Find the source code on", + "sourceCode": "Исходный код", + "findTheSourceCodeOn": "Найти исходный код на", "@findTheSourceCodeOn": { "description": "Text before link to Paperless Mobile GitHub" }, - "rememberDecision": "Remember my decision", - "defaultDownloadFileType": "Default Download File Type", + "rememberDecision": "Запомните мой выбор", + "defaultDownloadFileType": "Тип загружаемого файла по умолчанию", "@defaultDownloadFileType": { "description": "Label indicating the default filetype to download (one of archived, original and always ask)" }, - "defaultShareFileType": "Default Share File Type", + "defaultShareFileType": "Тип файла обмена по умолчанию", "@defaultShareFileType": { "description": "Label indicating the default filetype to share (one of archived, original and always ask)" }, - "alwaysAsk": "Always ask", + "alwaysAsk": "Всегда спрашивать", "@alwaysAsk": { "description": "Option to choose when the app should always ask the user which filetype to use" }, - "disableMatching": "Do not tag documents automatically", + "disableMatching": "Не отмечать документы автоматически", "@disableMatching": { "description": "One of the options for automatic tagging of documents" }, - "none": "None", + "none": "Ничего", "@none": { "description": "One of available enum values of matching algorithm for tags" }, - "logInToExistingAccount": "Log in to existing account", + "logInToExistingAccount": "Войти в существующий аккаунт", "@logInToExistingAccount": { "description": "Title shown on login page if at least one user is already known to the app." }, - "print": "Print", + "print": "Распечатать", "@print": { "description": "Tooltip for print button" }, - "managePermissions": "Manage permissions", + "managePermissions": "Управление разрешениями", "@managePermissions": { "description": "Button which leads user to manage permissions page" }, - "errorRetrievingServerVersion": "An error occurred trying to resolve the server version.", + "errorRetrievingServerVersion": "Произошла ошибка при попытке определить версию сервера.", "@errorRetrievingServerVersion": { "description": "Message shown at the bottom of the settings page when the remote server version could not be resolved." }, - "resolvingServerVersion": "Resolving server version...", + "resolvingServerVersion": "Определение версии сервера...", "@resolvingServerVersion": { "description": "Message shown while the app is loading the remote server version." }, - "goToLogin": "Go to login", + "goToLogin": "Перейти ко входу", "@goToLogin": { "description": "Label of the button shown on the login page to skip logging in to existing accounts and navigate user to login page" }, - "export": "Export", + "export": "Экспорт", "@export": { "description": "Label for button that exports scanned images to pdf (before upload)" }, - "invalidFilenameCharacter": "Invalid character(s) found in filename: {characters}", + "invalidFilenameCharacter": "В имени файла обнаружены недопустимые символы: {characters}", "@invalidFilenameCharacter": { "description": "For validating filename in export dialogue" }, - "exportScansToPdf": "Export scans to PDF", + "exportScansToPdf": "Экспортировать сканирования в PDF", "@exportScansToPdf": { "description": "title of the alert dialog when exporting scans to pdf" }, - "allScansWillBeMerged": "All scans will be merged into a single PDF file.", - "behavior": "Behavior", + "allScansWillBeMerged": "Все сканированные файлы будут объединены в один PDF-файл.", + "behavior": "Поведение", "@behavior": { "description": "Title of the settings concerning app beahvior" }, - "theme": "Theme", + "theme": "Тема", "@theme": { "description": "Title of the theme mode setting" }, - "clearCache": "Clear cache", + "clearCache": "Очистить кэш", "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {byteString}", + "freeBytes": "Свободно {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, - "calculatingDots": "Calculating...", + "calculatingDots": "Расчет...", "@calculatingDots": { "description": "Text shown when the byte size is still being calculated" }, - "freedDiskSpace": "Successfully freed {bytes} of disk space.", + "freedDiskSpace": "{bytes} успешно освобождено на диске.", "@freedDiskSpace": { "description": "Message shown after clearing storage" }, - "uploadScansAsPdf": "Upload scans as PDF", + "uploadScansAsPdf": "Загрузить сканирование в PDF", "@uploadScansAsPdf": { "description": "Title of the setting which toggles whether scans are always uploaded as pdf" }, - "convertSinglePageScanToPdf": "Always convert single page scans to PDF before uploading", + "convertSinglePageScanToPdf": "Всегда конвертировать одну страницу в PDF перед загрузкой", "@convertSinglePageScanToPdf": { "description": "description of the upload scans as pdf setting" }, - "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", + "loginRequiredPermissionsHint": "Использование Paperless Mobile требует минимального набора разрешений пользователя, начиная с версии paperless-ngx 1.14.0 и выше. Поэтому убедитесь, что у пользователя, который будет входить в систему, есть права на просмотр других пользователей (Пользователь → Вид) и настроек (Настройки пользовательского интерфейса → Вид). Если эти права отсутствуют, обратитесь к администратору сервера paperless-ngx.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." }, - "missingPermissions": "You do not have the necessary permissions to perform this action.", + "missingPermissions": "У вас нет необходимых разрешений для выполнения этого действия.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." }, - "editView": "Edit View", + "editView": "Редактировать вид", "@editView": { "description": "Title of the edit saved view page" }, - "donate": "Donate", + "donate": "Пожертвовать", "@donate": { "description": "Label of the in-app donate button" }, - "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "donationDialogContent": "Спасибо, что решили поддержать это приложение! В соответствии с политикой платежей Google и Apple, ссылки, ведущие на пожертвования, не могут отображаться в приложении. Даже ссылки на страницу репозитория проекта, по-видимому, не разрешены в данном контексте. Поэтому, возможно, стоит обратить внимание на раздел \"Пожертвования\" в README проекта. Мы очень ценим вашу поддержку и поддерживаем развитие этого приложения. Спасибо!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" }, - "noDocumentsFound": "No documents found.", + "noDocumentsFound": "Документы не найдены.", "@noDocumentsFound": { "description": "Message shown when no documents were found." }, - "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "couldNotDeleteCorrespondent": "Не удалось удалить корреспондента, попробуйте еще раз.", "@couldNotDeleteCorrespondent": { "description": "Message shown in snackbar when a correspondent could not be deleted." }, - "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "couldNotDeleteDocumentType": "Не удалось удалить тип документа, попробуйте еще раз.", "@couldNotDeleteDocumentType": { "description": "Message shown when a document type could not be deleted" }, - "couldNotDeleteTag": "Could not delete tag, please try again.", + "couldNotDeleteTag": "Не удалось удалить тег, попробуйте еще раз.", "@couldNotDeleteTag": { "description": "Message shown when a tag could not be deleted" }, - "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "couldNotDeleteStoragePath": "Не удалось удалить путь к хранилищу, попробуйте еще раз.", "@couldNotDeleteStoragePath": { "description": "Message shown when a storage path could not be deleted" }, - "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "couldNotUpdateCorrespondent": "Не удалось обновить корреспондента, попробуйте еще раз.", "@couldNotUpdateCorrespondent": { "description": "Message shown when a correspondent could not be updated" }, - "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "couldNotUpdateDocumentType": "Не удалось обновить тип документа, попробуйте еще раз.", "@couldNotUpdateDocumentType": { "description": "Message shown when a document type could not be updated" }, - "couldNotUpdateTag": "Could not update tag, please try again.", + "couldNotUpdateTag": "Не удалось обновить тег, попробуйте еще раз.", "@couldNotUpdateTag": { "description": "Message shown when a tag could not be updated" }, - "couldNotLoadServerInformation": "Could not load server information.", + "couldNotLoadServerInformation": "Не удалось загрузить информацию о сервере.", "@couldNotLoadServerInformation": { "description": "Message shown when the server information could not be loaded" }, - "couldNotLoadStatistics": "Could not load server statistics.", + "couldNotLoadStatistics": "Не удалось загрузить статистику сервера.", "@couldNotLoadStatistics": { "description": "Message shown when the server statistics could not be loaded" }, - "couldNotLoadUISettings": "Could not load UI settings.", + "couldNotLoadUISettings": "Не удалось загрузить настройки пользовательского интерфейса.", "@couldNotLoadUISettings": { "description": "Message shown when the UI settings could not be loaded" }, - "couldNotLoadTasks": "Could not load tasks.", + "couldNotLoadTasks": "Не удалось загрузить задания.", "@couldNotLoadTasks": { "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" }, - "userNotFound": "User could not be found.", + "userNotFound": "Не удалось найти пользователя.", "@userNotFound": { "description": "Message shown when the specified user (e.g. by id) could not be found" }, - "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "couldNotUpdateSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз.", "@couldNotUpdateSavedView": { "description": "Message shown when a saved view could not be updated" }, - "couldNotUpdateStoragePath": "Could not update storage path, please try again.", - "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "couldNotUpdateStoragePath": "Не удалось обновить путь к хранилищу, попробуйте еще раз.", + "savedViewSuccessfullyUpdated": "Сохраненный вид успешно обновлен.", "@savedViewSuccessfullyUpdated": { "description": "Message shown when a saved view was successfully updated." }, - "discardChanges": "Discard changes?", + "discardChanges": "Не сохранять изменения?", "@discardChanges": { "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." }, - "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "savedViewChangedDialogContent": "Условия фильтра активного вида изменились. Сброс фильтра будет утерян. Вы все равно хотите продолжить?", "@savedViewChangedDialogContent": { "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." }, - "createFromCurrentFilter": "Create from current filter", + "createFromCurrentFilter": "Создать из текущего фильтра", "@createFromCurrentFilter": { "description": "Tooltip of the \"New saved view\" button" }, - "home": "Home", + "home": "Домашняя страница", "@home": { "description": "Label of the \"Home\" route" }, - "welcomeUser": "Welcome, {name}!", + "welcomeUser": "Добро пожаловать, {name}!", "@welcomeUser": { "description": "Top message shown on the home page" }, - "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "noSavedViewOnHomepageHint": "Настройте сохраненный вид для отображения на вашей домашней страница и он будет отображаться здесь.", "@noSavedViewOnHomepageHint": { "description": "Message shown when there is no saved view to display on the home page." }, - "statistics": "Statistics", - "documentsInInbox": "Documents in inbox", - "totalDocuments": "Total documents", - "totalCharacters": "Total characters", - "showAll": "Show all", + "statistics": "Статистика", + "documentsInInbox": "Документы во входящих", + "totalDocuments": "Всего документов", + "totalCharacters": "Всего символов", + "showAll": "Показать все", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" }, - "userAlreadyExists": "This user already exists.", + "userAlreadyExists": "Этот пользователь уже существует.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "Вы еще не сохранили ни одного вида, создайте его и он будет показан здесь.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index f7795c7a..7fa97a49 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 416bb005..d2a51c1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,15 +28,14 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; +import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -45,6 +44,7 @@ import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; @@ -84,17 +84,17 @@ Future _initHive() async { void main() async { runZonedGuarded(() async { Paint.enableDithering = true; - if (kDebugMode) { - // URL: http://localhost:3131 - // Login: admin:test - await LocalMockApiServer( - // RandomDelayGenerator( - // const Duration(milliseconds: 100), - // const Duration(milliseconds: 800), - // ), - ) - .start(); - } + // if (kDebugMode) { + // // URL: http://localhost:3131 + // // Login: admin:test + // await LocalMockApiServer( + // // RandomDelayGenerator( + // // const Duration(milliseconds: 100), + // // const Duration(milliseconds: 800), + // // ), + // ) + // .start(); + // } await _initHive(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final globalSettingsBox = @@ -154,7 +154,7 @@ void main() async { connectivityStatusService, ); await authenticationCubit.restoreSessionState(); - + await ShareIntentQueue.instance.initialize(); runApp( MultiProvider( providers: [ @@ -240,6 +240,7 @@ class _GoRouterShellState extends State { routes: [ $settingsRoute, $savedViewsRoute, + $uploadQueueRoute, StatefulShellRoute( navigatorContainerBuilder: (context, navigationShell, children) { return children[navigationShell.currentIndex]; @@ -281,7 +282,6 @@ class _GoRouterShellState extends State { case UnauthenticatedState(): const LoginRoute().go(context); break; - case RequiresLocalAuthenticationState(): const VerifyIdentityRoute().go(context); break; @@ -292,6 +292,8 @@ class _GoRouterShellState extends State { const LandingRoute().go(context); break; case AuthenticationErrorState(): + const LoginRoute().go(context); + break; } }, child: GlobalSettingsBuilder( diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 0dc9dd4d..ed91b827 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -20,4 +20,5 @@ class R { static const settings = "settings"; static const linkedDocuments = "linkedDocuments"; static const bulkEditDocuments = "bulkEditDocuments"; + static const uploadQueue = "uploadQueue"; } diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart index 4bfb01d2..c7ce700a 100644 --- a/lib/routes/typed/branches/scanner_route.dart +++ b/lib/routes/typed/branches/scanner_route.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -51,7 +53,7 @@ class ScannerRoute extends GoRouteData { class DocumentUploadRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - final Uint8List $extra; + final FutureOr $extra; final String? title; final String? filename; final String? fileExtension; diff --git a/lib/routes/typed/branches/upload_queue_route.dart b/lib/routes/typed/branches/upload_queue_route.dart new file mode 100644 index 00000000..fa3327ae --- /dev/null +++ b/lib/routes/typed/branches/upload_queue_route.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/sharing/view/consumption_queue_view.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'upload_queue_route.g.dart'; + +@TypedGoRoute( + path: "/upload-queue", + name: R.uploadQueue, +) +class UploadQueueRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) { + return const ConsumptionQueueView(); + } +} diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart index 99f29a40..e1004795 100644 --- a/lib/routes/typed/shells/provider_shell_route.dart +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; @@ -7,7 +6,12 @@ import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/features/home/view/home_shell_widget.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:provider/provider.dart'; + +/// Key used to access //part 'provider_shell_route.g.dart'; //TODO: Wait for https://github.com/flutter/flutter/issues/127371 to be merged @@ -66,7 +70,11 @@ class ProviderShellRoute extends ShellRouteData { localUserId: authenticatedUser.id, paperlessApiVersion: authenticatedUser.apiVersion, paperlessProviderFactory: apiFactory, - child: navigator, + child: ChangeNotifierProvider( + create: (context) => ConsumptionChangeNotifier() + ..loadFromConsumptionDirectory(userId: currentUserId), + child: UploadQueueShell(child: navigator), + ), ); } } diff --git a/packages/paperless_api/lib/src/models/task/task.dart b/packages/paperless_api/lib/src/models/task/task.dart index 59b863fe..43be02bc 100644 --- a/packages/paperless_api/lib/src/models/task/task.dart +++ b/packages/paperless_api/lib/src/models/task/task.dart @@ -21,6 +21,8 @@ class Task extends Equatable { @JsonKey(fromJson: tryParseNullable) final int? relatedDocument; + bool get isSuccess => status == TaskStatus.success; + const Task({ required this.id, this.taskId, diff --git a/pubspec.lock b/pubspec.lock index 986c05e4..212a30b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -479,6 +479,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "62f346340a96192070e31e3f2a1bd30a28530f1fe8be978821e06cd56b74d6d2" + url: "https://pub.dev" + source: hosted + version: "4.2.0+1" flutter_bloc: dependency: "direct main" description: @@ -1664,6 +1672,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6e542d95..a77281d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,6 +94,8 @@ dependencies: fl_chart: ^0.63.0 palette_generator: ^0.3.3+2 defer_pointer: ^0.0.2 + transparent_image: ^2.0.1 + flutter_animate: ^4.2.0+1 dependency_overrides: intl: ^0.18.1 From ad23df4f8927ce63e2ddb4e27ea1a39fdb7d1c52 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 3 Oct 2023 17:49:38 +0200 Subject: [PATCH 11/12] fix: Improve receiving shares --- lib/core/config/hive/hive_config.dart | 1 - lib/core/config/hive/hive_extensions.dart | 3 - lib/features/app_drawer/view/app_drawer.dart | 2 +- .../cubit/document_details_cubit.dart | 5 + .../widgets/document_download_button.dart | 3 + .../view/document_edit_page.dart | 14 +- .../document_scan/view/scanner_page.dart | 2 +- .../document_upload_preparation_page.dart | 70 ++++---- .../documents/view/pages/documents_page.dart | 7 +- lib/features/home/view/home_shell_widget.dart | 8 +- .../login/cubit/authentication_cubit.dart | 103 +++++++----- .../login/cubit/authentication_state.dart | 8 + lib/features/login/view/add_account_page.dart | 124 +++++++------- lib/features/login/view/login_page.dart | 3 + .../services/local_notification_service.dart | 34 ++-- .../settings/view/manage_accounts_page.dart | 5 +- .../sharing/cubit/receive_share_cubit.dart | 10 +- .../sharing/view/consumption_queue_view.dart | 93 ++++------- .../dialog/discard_shared_file_dialog.dart | 16 +- .../dialog/pending_files_info_dialog.dart | 29 ++++ .../sharing/view/widgets/file_thumbnail.dart | 7 +- .../view/widgets/upload_queue_shell.dart | 153 ++++++++++-------- .../tasks/cubit/task_status_cubit.dart | 26 --- .../tasks/model/pending_tasks_notifier.dart | 68 ++++++++ lib/main.dart | 31 +++- lib/routes/routes.dart | 2 + .../typed/shells/provider_shell_route.dart | 6 +- .../typed/top_level/checking_login_route.dart | 23 +++ .../typed/top_level/logging_out_route.dart | 23 +++ 29 files changed, 530 insertions(+), 349 deletions(-) create mode 100644 lib/features/sharing/view/dialog/pending_files_info_dialog.dart delete mode 100644 lib/features/tasks/cubit/task_status_cubit.dart create mode 100644 lib/features/tasks/model/pending_tasks_notifier.dart create mode 100644 lib/routes/typed/top_level/checking_login_route.dart create mode 100644 lib/routes/typed/top_level/logging_out_route.dart diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart index 8952bc90..6557f20a 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/config/hive/hive_config.dart @@ -18,7 +18,6 @@ class HiveBoxes { static const localUserCredentials = 'localUserCredentials'; static const localUserAccount = 'localUserAccount'; static const localUserAppState = 'localUserAppState'; - static const localUserSettings = 'localUserSettings'; static const hosts = 'hosts'; } diff --git a/lib/core/config/hive/hive_extensions.dart b/lib/core/config/hive/hive_extensions.dart index 83b88235..c519dcd2 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/config/hive/hive_extensions.dart @@ -8,7 +8,6 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; /// /// Opens an encrypted box, calls [callback] with the now opened box, awaits @@ -53,8 +52,6 @@ extension HiveBoxAccessors on HiveInterface { box(HiveBoxes.localUserAccount); Box get localUserAppStateBox => box(HiveBoxes.localUserAppState); - Box get localUserSettingsBox => - box(HiveBoxes.localUserSettings); Box get globalSettingsBox => box(HiveBoxes.globalSettings); } diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 14d0a649..61b41bbc 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -108,7 +108,7 @@ class AppDrawer extends StatelessWidget { final child = ListTile( dense: true, leading: const Icon(Icons.drive_folder_upload_outlined), - title: const Text("Upload Queue"), + title: const Text("Pending Files"), onTap: () { UploadQueueRoute().push(context); }, diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 94291eac..cf09e950 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_description.dart'; @@ -120,6 +121,7 @@ class DocumentDetailsCubit extends Cubit { Future downloadDocument({ bool downloadOriginal = false, required String locale, + required String userId, }) async { if (state.metaData == null) { await loadMetaData(); @@ -141,6 +143,7 @@ class DocumentDetailsCubit extends Cubit { filePath: filePath, finished: true, locale: locale, + userId: userId, ); } @@ -150,6 +153,7 @@ class DocumentDetailsCubit extends Cubit { filePath: filePath, finished: false, locale: locale, + userId: userId, ); await _api.downloadToFile( @@ -163,6 +167,7 @@ class DocumentDetailsCubit extends Cubit { filePath: filePath, finished: true, locale: locale, + userId: userId, ); debugPrint("Downloaded file to $filePath"); } diff --git a/lib/features/document_details/view/widgets/document_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 9d348312..5f66073c 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; @@ -90,9 +91,11 @@ class _DocumentDownloadButtonState extends State { } setState(() => _isDownloadPending = true); + final userId = context.read().id; await context.read().downloadDocument( downloadOriginal: original, locale: globalSettings.preferredLocaleSubtag, + userId: userId, ); // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); } on PaperlessApiException catch (error, stackTrace) { diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index defa4199..57438056 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -305,13 +305,13 @@ class _DocumentEditPageState extends State { var mergedDocument = document.copyWith( title: values[fkTitle], created: values[fkCreatedDate], - documentType: () => (values[fkDocumentType] as IdQueryParameter) - .whenOrNull(fromId: (id) => id), - correspondent: () => (values[fkCorrespondent] as IdQueryParameter) - .whenOrNull(fromId: (id) => id), - storagePath: () => (values[fkStoragePath] as IdQueryParameter) - .whenOrNull(fromId: (id) => id), - tags: (values[fkTags] as IdsTagsQuery).include, + documentType: () => (values[fkDocumentType] as IdQueryParameter?) + ?.whenOrNull(fromId: (id) => id), + correspondent: () => (values[fkCorrespondent] as IdQueryParameter?) + ?.whenOrNull(fromId: (id) => id), + storagePath: () => (values[fkStoragePath] as IdQueryParameter?) + ?.whenOrNull(fromId: (id) => id), + tags: (values[fkTags] as IdsTagsQuery?)?.include, content: values[fkContent], ); setState(() { diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 0a5d788a..bda703ba 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -22,7 +22,7 @@ import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_ima import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 4412b0ca..cb9ee110 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -60,7 +60,6 @@ class _DocumentUploadPreparationPageState static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss"); final GlobalKey _formKey = GlobalKey(); - Color? _titleColor; Map _errors = {}; bool _isUploadLoading = false; late bool _syncTitleAndFilename; @@ -71,10 +70,6 @@ class _DocumentUploadPreparationPageState void initState() { super.initState(); _syncTitleAndFilename = widget.filename == null && widget.title == null; - _computeAverageColor().then((value) { - _titleColor = - value.computeLuminance() > 0.5 ? Colors.black : Colors.white; - }); initializeDateFormatting(); } @@ -102,9 +97,7 @@ class _DocumentUploadPreparationPageState handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( - leading: BackButton( - color: _titleColor, - ), + leading: BackButton(), pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( @@ -112,20 +105,17 @@ class _DocumentUploadPreparationPageState future: widget.fileBytes, builder: (context, snapshot) { if (!snapshot.hasData) { - return const SizedBox.shrink(); + return SizedBox.shrink(); } return FileThumbnail( bytes: snapshot.data!, fit: BoxFit.fitWidth, + width: MediaQuery.sizeOf(context).width, ); }, ), - title: Text( - S.of(context)!.prepareDocument, - style: TextStyle( - color: _titleColor, - ), - ), + title: Text(S.of(context)!.prepareDocument), + collapseMode: CollapseMode.pin, ), bottom: _isUploadLoading ? PreferredSize( @@ -416,32 +406,32 @@ class _DocumentUploadPreparationPageState return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase(); } - Future _computeAverageColor() async { - final bitmap = img.decodeImage(await widget.fileBytes); - if (bitmap == null) { - return Colors.black; - } - int redBucket = 0; - int greenBucket = 0; - int blueBucket = 0; - int pixelCount = 0; + // Future _computeAverageColor() async { + // final bitmap = img.decodeImage(await widget.fileBytes); + // if (bitmap == null) { + // return Colors.black; + // } + // int redBucket = 0; + // int greenBucket = 0; + // int blueBucket = 0; + // int pixelCount = 0; - for (int y = 0; y < bitmap.height; y++) { - for (int x = 0; x < bitmap.width; x++) { - final c = bitmap.getPixel(x, y); + // for (int y = 0; y < bitmap.height; y++) { + // for (int x = 0; x < bitmap.width; x++) { + // final c = bitmap.getPixel(x, y); - pixelCount++; - redBucket += c.r.toInt(); - greenBucket += c.g.toInt(); - blueBucket += c.b.toInt(); - } - } + // pixelCount++; + // redBucket += c.r.toInt(); + // greenBucket += c.g.toInt(); + // blueBucket += c.b.toInt(); + // } + // } - return Color.fromRGBO( - redBucket ~/ pixelCount, - greenBucket ~/ pixelCount, - blueBucket ~/ pixelCount, - 1, - ); - } + // return Color.fromRGBO( + // redBucket ~/ pixelCount, + // greenBucket ~/ pixelCount, + // blueBucket ~/ pixelCount, + // 1, + // ); + // } } diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 4aca245c..d6a1e3a9 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -21,7 +21,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/selection/view_ import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -59,7 +59,7 @@ class _DocumentsPageState extends State { @override void initState() { super.initState(); - context.read().addListener(_onTasksChanged); + // context.read().addListener(_onTasksChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _nestedScrollViewKey.currentState!.innerController .addListener(_scrollExtentChangedListener); @@ -126,8 +126,7 @@ class _DocumentsPageState extends State { void dispose() { _nestedScrollViewKey.currentState?.innerController .removeListener(_scrollExtentChangedListener); - context.read().removeListener(_onTasksChanged); - + // context.read().removeListener(_onTasksChanged); super.dispose(); } diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 07709d44..4a01d1ed 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -16,9 +17,14 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; +import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:provider/provider.dart'; class HomeShellWidget extends StatelessWidget { diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 177ca7d8..9d078b1e 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -20,6 +20,7 @@ import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -44,34 +45,35 @@ class AuthenticationCubit extends Cubit { ClientCertificate? clientCertificate, }) async { assert(credentials.username != null && credentials.password != null); + emit(const CheckingLoginState()); final localUserId = "${credentials.username}@$serverUrl"; _debugPrintMessage( "login", "Trying to login $localUserId...", ); - await _addUser( - localUserId, - serverUrl, - credentials, - clientCertificate, - _sessionManager, - ); + try { + await _addUser( + localUserId, + serverUrl, + credentials, + clientCertificate, + _sessionManager, + ); - // Mark logged in user as currently active user. - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.loggedInUserId = localUserId; - await globalSettings.save(); + // Mark logged in user as currently active user. + final globalSettings = + Hive.box(HiveBoxes.globalSettings).getValue()!; + globalSettings.loggedInUserId = localUserId; + await globalSettings.save(); - emit( - AuthenticatedState( - localUserId: localUserId, - ), - ); - _debugPrintMessage( - "login", - "User successfully logged in.", - ); + emit(AuthenticatedState(localUserId: localUserId)); + _debugPrintMessage( + "login", + "User successfully logged in.", + ); + } catch (error) { + emit(const UnauthenticatedState()); + } } /// Switches to another account if it exists. @@ -156,10 +158,8 @@ class AuthenticationCubit extends Cubit { } Future removeAccount(String userId) async { - final userAccountBox = - Hive.box(HiveBoxes.localUserAccount); - final userAppStateBox = - Hive.box(HiveBoxes.localUserAppState); + final userAccountBox = Hive.localUserAccountBox; + final userAppStateBox = Hive.localUserAppStateBox; await FileService.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); @@ -263,9 +263,13 @@ class AuthenticationCubit extends Cubit { "restoreSessionState", "Current session state successfully updated.", ); - final hasInternetConnection = - await _connectivityService.isConnectedToInternet(); - if (hasInternetConnection) { + final isPaperlessServerReachable = + await _connectivityService.isPaperlessServerReachable( + localUserAccount.serverUrl, + authentication.clientCertificate, + ) == + ReachabilityStatus.reachable; + if (isPaperlessServerReachable) { _debugPrintMessage( "restoreSessionMState", "Updating server user...", @@ -283,7 +287,7 @@ class AuthenticationCubit extends Cubit { } else { _debugPrintMessage( "restoreSessionMState", - "Skipping update of server user (no internet connection).", + "Skipping update of server user (server could not be reached).", ); } @@ -295,14 +299,18 @@ class AuthenticationCubit extends Cubit { ); } - Future logout() async { + Future logout([bool removeAccount = false]) async { + emit(const LogginOutState()); _debugPrintMessage( "logout", "Trying to log out current user...", ); await _resetExternalState(); - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; + final globalSettings = Hive.globalSettingsBox.getValue()!; + final userId = globalSettings.loggedInUserId!; + if (removeAccount) { + this.removeAccount(userId); + } globalSettings.loggedInUserId = null; await globalSettings.save(); @@ -459,19 +467,32 @@ class AuthenticationCubit extends Cubit { return serverUser.id; } - Future _getApiVersion(Dio dio) async { + Future _getApiVersion( + Dio dio, { + Duration? timeout, + int defaultValue = 2, + }) async { _debugPrintMessage( "_getApiVersion", "Trying to fetch API version...", ); - final response = await dio.get("/api/"); - final apiVersion = - int.parse(response.headers.value('x-api-version') ?? "3"); - _debugPrintMessage( - "_getApiVersion", - "API version ($apiVersion) successfully retrieved.", - ); - return apiVersion; + try { + final response = await dio.get( + "/api/", + options: Options( + sendTimeout: timeout, + ), + ); + final apiVersion = + int.parse(response.headers.value('x-api-version') ?? "3"); + _debugPrintMessage( + "_getApiVersion", + "API version ($apiVersion) successfully retrieved.", + ); + return apiVersion; + } on DioException catch (e) { + return defaultValue; + } } /// Fetches possibly updated (permissions, name, updated server version and thus new user model, ...) remote user data. diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index db4455cb..bc4d29c8 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -15,6 +15,14 @@ class RequiresLocalAuthenticationState extends AuthenticationState { const RequiresLocalAuthenticationState(); } +class CheckingLoginState extends AuthenticationState { + const CheckingLoginState(); +} + +class LogginOutState extends AuthenticationState { + const LogginOutState(); +} + class AuthenticatedState extends AuthenticationState { final String localUserId; diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index 7e1bdc60..5c48e445 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -57,72 +57,76 @@ class _AddAccountPageState extends State { @override Widget build(BuildContext context) { - final localAccounts = - Hive.box(HiveBoxes.localUserAccount); - return Scaffold( - resizeToAvoidBottomInset: false, - body: FormBuilder( - key: _formKey, - child: PageView( - controller: _pageController, - scrollBehavior: NeverScrollableScrollBehavior(), - children: [ - if (widget.showLocalAccounts && localAccounts.isNotEmpty) - Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.logInToExistingAccount), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - child: Text(S.of(context)!.goToLogin), - onPressed: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, + return ValueListenableBuilder( + valueListenable: + Hive.box(HiveBoxes.localUserAccount).listenable(), + builder: (context, localAccounts, child) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: FormBuilder( + key: _formKey, + child: PageView( + controller: _pageController, + scrollBehavior: NeverScrollableScrollBehavior(), + children: [ + if (widget.showLocalAccounts && localAccounts.isNotEmpty) + Scaffold( + appBar: AppBar( + title: Text(S.of(context)!.logInToExistingAccount), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + child: Text(S.of(context)!.goToLogin), + onPressed: () { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ], ), - ], + ), + body: ListView.builder( + itemBuilder: (context, index) { + final account = localAccounts.values.elementAt(index); + return Card( + child: UserAccountListTile( + account: account, + onTap: () { + context + .read() + .switchAccount(account.id); + }, + ), + ); + }, + itemCount: localAccounts.length, + ), ), - ), - body: ListView.builder( - itemBuilder: (context, index) { - final account = localAccounts.values.elementAt(index); - return Card( - child: UserAccountListTile( - account: account, - onTap: () { - context - .read() - .switchAccount(account.id); - }, - ), + ServerConnectionPage( + titleText: widget.titleString, + formBuilderKey: _formKey, + onContinue: () { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, ); }, - itemCount: localAccounts.length, ), - ), - ServerConnectionPage( - titleText: widget.titleString, - formBuilderKey: _formKey, - onContinue: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ServerLoginPage( - formBuilderKey: _formKey, - submitText: widget.submitText, - onSubmit: _login, + ServerLoginPage( + formBuilderKey: _formKey, + submitText: widget.submitText, + onSubmit: _login, + ), + ], ), - ], - ), - ), + ), + ); + }, ); } diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index afae0fc4..44d2e367 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -5,6 +5,7 @@ import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; @@ -71,6 +72,8 @@ class LoginPage extends StatelessWidget { stackTrace, ); //TODO: Check if we can show error message directly on field here. } + } on InfoMessageException catch (error) { + showInfoMessage(context, error); } catch (unknownError, stackTrace) { showGenericError(context, unknownError.toString(), stackTrace); } diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index f4e7f857..01c8246d 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -18,6 +18,8 @@ class LocalNotificationService { LocalNotificationService(); + final Map> _pendingNotifications = {}; + Future initialize() async { const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('paperless_logo_green'); @@ -51,6 +53,7 @@ class LocalNotificationService { required String filePath, required bool finished, required String locale, + required String userId, }) async { final tr = await S.delegate.load(Locale(locale)); @@ -88,6 +91,15 @@ class LocalNotificationService { ).toJson(), ), ); //TODO: INTL + _addNotification(userId, id); + } + + void _addNotification(String userId, int notificationId) { + _pendingNotifications.update( + userId, + (notifications) => [...notifications, notificationId], + ifAbsent: () => [notificationId], + ); } Future notifyFileSaved({ @@ -119,24 +131,20 @@ class LocalNotificationService { ), iOS: DarwinNotificationDetails( attachments: [ - DarwinNotificationAttachment( - filePath, - ), + DarwinNotificationAttachment(filePath), ], ), ), payload: jsonEncode( - OpenDownloadedDocumentPayload( - filePath: filePath, - ).toJson(), + OpenDownloadedDocumentPayload(filePath: filePath).toJson(), ), ); } //TODO: INTL - Future notifyTaskChanged(Task task) { + Future notifyTaskChanged(Task task, {required String userId}) async { log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); - int id = task.id; + int id = task.id + 1000; final status = task.status; late String title; late String? body; @@ -171,7 +179,7 @@ class LocalNotificationService { default: break; } - return _plugin.show( + await _plugin.show( id, title, body, @@ -204,6 +212,13 @@ class LocalNotificationService { ), payload: jsonEncode(payload), ); + _addNotification(userId, id); + } + + Future cancelUserNotifications(String userId) async { + await Future.wait([ + for (var id in _pendingNotifications[userId] ?? []) _plugin.cancel(id), + ]); } void onDidReceiveLocalNotification( @@ -272,6 +287,7 @@ class LocalNotificationService { } } +@protected void onDidReceiveBackgroundNotificationResponse(NotificationResponse response) { //TODO: When periodic background inbox check is implemented, notification tap is handled here debugPrint(response.toString()); diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index 1d412625..5cce0354 100644 --- a/lib/features/settings/view/manage_accounts_page.dart +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -70,12 +70,9 @@ class ManageAccountsPage extends StatelessWidget { ], onSelected: (value) async { if (value == 0) { - final currentUser = globalSettings.loggedInUserId!; - await context.read().logout(); - Navigator.of(context).pop(); await context .read() - .removeAccount(currentUser); + .logout(true); } }, ), diff --git a/lib/features/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart index a6322e64..19317d49 100644 --- a/lib/features/sharing/cubit/receive_share_cubit.dart +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -1,20 +1,24 @@ +import 'dart:async'; import 'dart:io'; -import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:path/path.dart' as p; -import 'package:provider/provider.dart'; part 'receive_share_state.dart'; class ConsumptionChangeNotifier extends ChangeNotifier { List pendingFiles = []; - ConsumptionChangeNotifier(); + final Completer _restored = Completer(); + + Future get isInitialized => _restored.future; Future loadFromConsumptionDirectory({required String userId}) async { pendingFiles = await _getCurrentFiles(userId); + if (!_restored.isCompleted) { + _restored.complete(); + } notifyListeners(); } diff --git a/lib/features/sharing/view/consumption_queue_view.dart b/lib/features/sharing/view/consumption_queue_view.dart index c33527d1..e2d2617e 100644 --- a/lib/features/sharing/view/consumption_queue_view.dart +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -1,8 +1,4 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; @@ -20,13 +16,13 @@ class ConsumptionQueueView extends StatelessWidget { final currentUser = context.watch(); return Scaffold( appBar: AppBar( - title: Text("Upload Queue"), //TODO: INTL + title: Text("Pending Files"), //TODO: INTL ), body: Consumer( builder: (context, value, child) { if (value.pendingFiles.isEmpty) { return Center( - child: Text("No pending files."), + child: Text("There are no pending files."), //TODO: INTL ); } return ListView.builder( @@ -34,7 +30,37 @@ class ConsumptionQueueView extends StatelessWidget { final file = value.pendingFiles.elementAt(index); final filename = p.basename(file.path); return ListTile( - title: Text(filename), + title: Text( + filename, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + subtitle: Row( + children: [ + ActionChip( + label: Text(S.of(context)!.upload), + avatar: const Icon(Icons.file_upload_outlined), + onPressed: () { + consumeLocalFile( + context, + file: file, + userId: currentUser.id, + ); + }, + ), + const SizedBox(width: 8), + ActionChip( + label: Text(S.of(context)!.discard), + avatar: const Icon(Icons.delete), + onPressed: () { + context.read().discardFile( + file, + userId: currentUser.id, + ); + }, + ), + ], + ), leading: Padding( padding: const EdgeInsets.all(4), child: ClipRRect( @@ -46,60 +72,7 @@ class ConsumptionQueueView extends StatelessWidget { ), ), ), - trailing: IconButton( - icon: Icon(Icons.delete), - onPressed: () { - context - .read() - .discardFile(file, userId: currentUser.id); - }, - ), ); - return Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Column( - children: [ - Text(filename, maxLines: 1), - SizedBox( - height: 56, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - ActionChip( - label: Text(S.of(context)!.upload), - avatar: Icon(Icons.file_upload_outlined), - onPressed: () { - consumeLocalFile( - context, - file: file, - userId: currentUser.id, - ); - }, - ), - SizedBox(width: 8), - ActionChip( - label: Text(S.of(context)!.discard), - avatar: Icon(Icons.delete), - onPressed: () { - context - .read() - .discardFile( - file, - userId: currentUser.id, - ); - }, - ), - ], - ), - ), - ], - ).padded(), - ), - ], - ).padded(); }, itemCount: value.pendingFiles.length, ); diff --git a/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart index 311172ee..ae816205 100644 --- a/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart +++ b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart @@ -6,7 +6,7 @@ import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button. import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart'; import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:transparent_image/transparent_image.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; class DiscardSharedFileDialog extends StatelessWidget { final FutureOr bytes; @@ -24,13 +24,13 @@ class DiscardSharedFileDialog extends StatelessWidget { if (!snapshot.hasData) { return const CircularProgressIndicator(); } - return LimitedBox( - maxHeight: 200, - maxWidth: 200, - child: FadeInImage( - fit: BoxFit.contain, - placeholder: MemoryImage(kTransparentImage), - image: MemoryImage(snapshot.data!), + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: FileThumbnail( + bytes: snapshot.data!, + width: 150, + height: 100, + fit: BoxFit.cover, ), ); }, diff --git a/lib/features/sharing/view/dialog/pending_files_info_dialog.dart b/lib/features/sharing/view/dialog/pending_files_info_dialog.dart new file mode 100644 index 00000000..d4d087aa --- /dev/null +++ b/lib/features/sharing/view/dialog/pending_files_info_dialog.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.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/generated/l10n/app_localizations.dart'; + +class PendingFilesInfoDialog extends StatelessWidget { + final List pendingFiles; + const PendingFilesInfoDialog({super.key, required this.pendingFiles}); + + @override + Widget build(BuildContext context) { + final fileCount = pendingFiles.length; + return AlertDialog( + title: Text("Pending Files"), + content: Text( + "$fileCount files are waiting to be uploaded. Do you want to upload them now?", + ), + actions: [ + DialogCancelButton(), + DialogConfirmButton( + label: S.of(context)!.upload, + ), + ], + ); + } +} diff --git a/lib/features/sharing/view/widgets/file_thumbnail.dart b/lib/features/sharing/view/widgets/file_thumbnail.dart index f4e8c91a..8abfa202 100644 --- a/lib/features/sharing/view/widgets/file_thumbnail.dart +++ b/lib/features/sharing/view/widgets/file_thumbnail.dart @@ -28,13 +28,15 @@ class FileThumbnail extends StatefulWidget { class _FileThumbnailState extends State { late String? mimeType; - + late final Future _fileBytes; @override void initState() { super.initState(); mimeType = widget.file != null ? mime.lookupMimeType(widget.file!.path) : mime.lookupMimeType('', headerBytes: widget.bytes); + _fileBytes = widget.file?.readAsBytes().then(_convertPdfToPng) ?? + _convertPdfToPng(widget.bytes!); } @override @@ -45,8 +47,7 @@ class _FileThumbnailState extends State { height: widget.height, child: Center( child: FutureBuilder( - future: widget.file?.readAsBytes().then(_convertPdfToPng) ?? - _convertPdfToPng(widget.bytes!), + future: _fileBytes, builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox.shrink(); diff --git a/lib/features/sharing/view/widgets/upload_queue_shell.dart b/lib/features/sharing/view/widgets/upload_queue_shell.dart index 05c384fb..59d8f142 100644 --- a/lib/features/sharing/view/widgets/upload_queue_shell.dart +++ b/lib/features/sharing/view/widgets/upload_queue_shell.dart @@ -10,15 +10,16 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; -import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/dialog/pending_files_info_dialog.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; -import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:path/path.dart' as p; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; @@ -39,57 +40,40 @@ class _UploadQueueShellState extends State { ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); _subscription = ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); + context.read().addListener(_onTasksChanged); + + // WidgetsBinding.instance.addPostFrameCallback((_) async { + // final notifier = context.read(); + // await notifier.isInitialized; + // final pendingFiles = notifier.pendingFiles; + // if (pendingFiles.isEmpty) { + // return; + // } - // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // context.read().loadFromConsumptionDirectory( - // userId: context.read().id, - // ); - // final state = context.read().state; - // print("Current state is " + state.toString()); - // final files = state.files; - // if (files.isNotEmpty) { - // showSnackBar( + // final shouldProcess = await showDialog( + // context: context, + // builder: (context) => + // PendingFilesInfoDialog(pendingFiles: pendingFiles), + // ) ?? + // false; + // if (shouldProcess) { + // final userId = context.read().id; + // await consumeLocalFiles( // context, - // "You have ${files.length} shared files waiting to be uploaded.", - // action: SnackBarActionConfig( - // label: "Show me", - // onPressed: () { - // UploadQueueRoute().push(context); - // }, - // ), + // files: pendingFiles, + // userId: userId, // ); - // // showDialog( - // // context: context, - // // builder: (context) => AlertDialog( - // // title: Text("Pending files"), - // // content: Text( - // // "You have ${files.length} files waiting to be uploaded.", - // // ), - // // actions: [ - // // TextButton( - // // child: Text(S.of(context)!.gotIt), - // // onPressed: () { - // // Navigator.pop(context); - // // UploadQueueRoute().push(context); - // // }, - // // ), - // // ], - // // ), - // // ); // } // }); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - context.read().addListener(_onTasksChanged); - } - void _onTasksChanged() { final taskNotifier = context.read(); + final userId = context.read().id; for (var task in taskNotifier.value.values) { - context.read().notifyTaskChanged(task); + context + .read() + .notifyTaskChanged(task, userId: userId); } } @@ -103,23 +87,18 @@ class _UploadQueueShellState extends State { files: files, userId: userId, ); - final localFiles = notifier.pendingFiles; - for (int i = 0; i < localFiles.length; i++) { - final file = localFiles[i]; - await consumeLocalFile( - context, - file: file, - userId: userId, - exitAppAfterConsumed: i == localFiles.length - 1, - ); - } + consumeLocalFiles( + context, + files: files, + userId: userId, + exitAppAfterConsumed: true, + ); } } @override void dispose() { _subscription?.cancel(); - context.read().removeListener(_onTasksChanged); super.dispose(); } @@ -135,28 +114,43 @@ Future consumeLocalFile( required String userId, bool exitAppAfterConsumed = false, }) async { + final filename = p.basename(file.path); + final hasInternetConnection = + await context.read().isConnectedToInternet(); + if (!hasInternetConnection) { + showSnackBar( + context, + "Could not consume $filename", //TODO: INTL + details: S.of(context)!.youreOffline, + ); + return; + } final consumptionNotifier = context.read(); final taskNotifier = context.read(); - final ioFile = File(file.path); - // if (!await ioFile.exists()) { - // Fluttertoast.showToast( - // msg: S.of(context)!.couldNotAccessReceivedFile, - // toastLength: Toast.LENGTH_LONG, - // ); - // } - - final bytes = ioFile.readAsBytes(); + + final bytes = file.readAsBytes(); final shouldDirectlyUpload = Hive.globalSettingsBox.getValue()!.skipDocumentPreprarationOnUpload; if (shouldDirectlyUpload) { - final taskId = await context.read().create( - await bytes, - filename: p.basename(file.path), - title: p.basenameWithoutExtension(file.path), - ); - consumptionNotifier.discardFile(file, userId: userId); - if (taskId != null) { - taskNotifier.listenToTaskChanges(taskId); + try { + final taskId = await context.read().create( + await bytes, + filename: filename, + title: p.basenameWithoutExtension(file.path), + ); + consumptionNotifier.discardFile(file, userId: userId); + if (taskId != null) { + taskNotifier.listenToTaskChanges(taskId); + } + } catch (error) { + await Fluttertoast.showToast( + msg: S.of(context)!.couldNotUploadDocument, + ); + return; + } finally { + if (exitAppAfterConsumed) { + SystemNavigator.pop(); + } } } else { final result = await DocumentUploadRoute( @@ -193,3 +187,20 @@ Future consumeLocalFile( } } } + +Future consumeLocalFiles( + BuildContext context, { + required List files, + required String userId, + bool exitAppAfterConsumed = false, +}) async { + for (int i = 0; i < files.length; i++) { + final file = files[i]; + await consumeLocalFile( + context, + file: file, + userId: userId, + exitAppAfterConsumed: exitAppAfterConsumed && (i == files.length - 1), + ); + } +} diff --git a/lib/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart deleted file mode 100644 index 25dbcd43..00000000 --- a/lib/features/tasks/cubit/task_status_cubit.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; - -class PendingTasksNotifier extends ValueNotifier> { - final PaperlessTasksApi _api; - PendingTasksNotifier(this._api) : super({}); - - void listenToTaskChanges(String taskId) { - _api.listenForTaskChanges(taskId).forEach((task) { - value = {...value, taskId: task}; - notifyListeners(); - }).whenComplete( - () { - value = value..remove(taskId); - notifyListeners(); - }, - ); - } - - Future acknowledgeTasks(Iterable taskIds) async { - final tasks = value.values.where((task) => taskIds.contains(task.taskId)); - await Future.wait([for (var task in tasks) _api.acknowledgeTask(task)]); - value = value..removeWhere((key, value) => taskIds.contains(key)); - notifyListeners(); - } -} diff --git a/lib/features/tasks/model/pending_tasks_notifier.dart b/lib/features/tasks/model/pending_tasks_notifier.dart new file mode 100644 index 00000000..b1e1f036 --- /dev/null +++ b/lib/features/tasks/model/pending_tasks_notifier.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; + +class PendingTasksNotifier extends ValueNotifier> { + final PaperlessTasksApi _api; + + final Map _subscriptions = {}; + + PendingTasksNotifier(this._api) : super({}); + + @override + void dispose() { + stopListeningToTaskChanges(); + super.dispose(); + } + + void listenToTaskChanges(String taskId) { + final sub = _api.listenForTaskChanges(taskId).listen( + (task) { + if (value.containsKey(taskId)) { + final oldTask = value[taskId]!; + if (oldTask.status != task.status) { + // Only notify of changes if task status has changed... + value = {...value, taskId: task}; + notifyListeners(); + } + } else { + value = {...value, taskId: task}; + notifyListeners(); + } + }, + ); + sub + ..onDone(() { + sub.cancel(); + value = value..remove(taskId); + notifyListeners(); + }) + ..onError((_) { + sub.cancel(); + value = value..remove(taskId); + notifyListeners(); + }); + + _subscriptions.putIfAbsent(taskId, () => sub); + } + + void stopListeningToTaskChanges([String? taskId]) { + if (taskId != null) { + _subscriptions[taskId]?.cancel(); + _subscriptions.remove(taskId); + } else { + _subscriptions.forEach((key, value) { + value.cancel(); + _subscriptions.remove(key); + }); + } + } + + Future acknowledgeTasks(Iterable taskIds) async { + final tasks = value.values.where((task) => taskIds.contains(task.taskId)); + await Future.wait([for (var task in tasks) _api.acknowledgeTask(task)]); + value = value..removeWhere((key, value) => taskIds.contains(key)); + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index d2a51c1f..bc36a56e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -37,7 +38,9 @@ import 'package:paperless_mobile/features/notifications/services/local_notificat import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -47,6 +50,8 @@ import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/checking_login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/logging_out_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; @@ -234,6 +239,8 @@ class _GoRouterShellState extends State { $loginRoute, $verifyIdentityRoute, $switchingAccountsRoute, + $logginOutRoute, + $checkingLoginRoute, ShellRoute( navigatorKey: rootNavigatorKey, builder: ProviderShellRoute(widget.apiFactory).build, @@ -280,19 +287,33 @@ class _GoRouterShellState extends State { listener: (context, state) { switch (state) { case UnauthenticatedState(): - const LoginRoute().go(context); + _router.goNamed(R.login); break; case RequiresLocalAuthenticationState(): - const VerifyIdentityRoute().go(context); + _router.goNamed(R.verifyIdentity); break; case SwitchingAccountsState(): - const SwitchingAccountsRoute().go(context); + final userId = context.read().id; + context + .read() + .cancelUserNotifications(userId); + _router.goNamed(R.switchingAccounts); break; case AuthenticatedState(): - const LandingRoute().go(context); + _router.goNamed(R.landing); + break; + case CheckingLoginState(): + _router.goNamed(R.checkingLogin); + break; + case LogginOutState(): + final userId = context.read().id; + context + .read() + .cancelUserNotifications(userId); + _router.goNamed(R.loggingOut); break; case AuthenticationErrorState(): - const LoginRoute().go(context); + _router.goNamed(R.login); break; } }, diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index ed91b827..9df4834a 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -21,4 +21,6 @@ class R { static const linkedDocuments = "linkedDocuments"; static const bulkEditDocuments = "bulkEditDocuments"; static const uploadQueue = "uploadQueue"; + static const checkingLogin = "checkingLogin"; + static const loggingOut = "loggingOut"; } diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart index e1004795..0a150b6d 100644 --- a/lib/routes/typed/shells/provider_shell_route.dart +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -61,11 +61,15 @@ class ProviderShellRoute extends ShellRouteData { ) { final currentUserId = Hive.box(HiveBoxes.globalSettings) .getValue()! - .loggedInUserId!; + .loggedInUserId; + if (currentUserId == null) { + return const SizedBox.shrink(); + } final authenticatedUser = Hive.box(HiveBoxes.localUserAccount).get( currentUserId, )!; + return HomeShellWidget( localUserId: authenticatedUser.id, paperlessApiVersion: authenticatedUser.apiVersion, diff --git a/lib/routes/typed/top_level/checking_login_route.dart b/lib/routes/typed/top_level/checking_login_route.dart new file mode 100644 index 00000000..1a9d62be --- /dev/null +++ b/lib/routes/typed/top_level/checking_login_route.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'checking_login_route.g.dart'; + +@TypedGoRoute( + path: "/checking-login", + name: R.checkingLogin, +) +class CheckingLoginRoute extends GoRouteData { + const CheckingLoginRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return Scaffold( + body: Center( + child: Text("Logging in..."), + ), + ); + } +} diff --git a/lib/routes/typed/top_level/logging_out_route.dart b/lib/routes/typed/top_level/logging_out_route.dart new file mode 100644 index 00000000..d584be91 --- /dev/null +++ b/lib/routes/typed/top_level/logging_out_route.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'logging_out_route.g.dart'; + +@TypedGoRoute( + path: "/logging-out", + name: R.loggingOut, +) +class LogginOutRoute extends GoRouteData { + const LogginOutRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return Scaffold( + body: Center( + child: Text("Logging out..."), + ), + ); + } +} From a2c5ced3b7c559325faaf0c2b9ca9a75a186fb3f Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 6 Oct 2023 01:17:08 +0200 Subject: [PATCH 12/12] feat: bugfixes, finished go_router migration, implemented better visibility of states --- lib/core/bloc/bloc_refresh_listenable.dart | 20 - lib/core/bloc/document_status_cubit.dart | 8 - lib/core/navigation/push_routes.dart | 231 ---------- .../notifier/document_changed_notifier.dart | 4 + lib/core/service/file_description.dart | 20 - lib/core/service/file_service.dart | 6 +- lib/core/service/status_service.dart | 103 ----- lib/core/widgets/app_options_popup_menu.dart | 218 --------- .../form_builder_type_ahead.dart | 430 ------------------ lib/core/widgets/material/chips_input.dart | 288 ------------ .../widgets/bulk_edit_label_bottom_sheet.dart | 104 ----- .../widgets/label_bulk_selection_widget.dart | 30 -- .../cubit/document_details_cubit.dart | 71 +-- .../view/pages/document_details_page.dart | 98 ++-- .../view/document_edit_page.dart | 8 +- .../logic/services/decode.isolate.dart | 44 -- .../document_scan/view/scanner_page.dart | 33 +- .../cubit/document_search_cubit.dart | 8 +- .../view/document_search_bar.dart | 24 +- .../view/sliver_search_bar.dart | 6 +- .../cubit/document_upload_cubit.dart | 10 +- .../document_upload_preparation_page.dart | 2 - .../documents/view/pages/documents_page.dart | 9 +- .../view/widgets/document_preview.dart | 17 +- .../widgets/new_items_loading_widget.dart | 11 - .../widgets/search/document_filter_panel.dart | 1 - lib/features/home/view/home_shell_widget.dart | 24 +- lib/features/home/view/route_description.dart | 41 -- .../view/scaffold_with_navigation_bar.dart | 13 +- .../view/widget/verify_identity_page.dart | 78 ---- lib/features/inbox/cubit/inbox_cubit.dart | 54 ++- lib/features/inbox/view/pages/inbox_page.dart | 1 + .../view/widgets/storage_path_widget.dart | 35 -- .../view/widgets/fullscreen_tags_form.dart | 18 +- .../view/widgets/fullscreen_label_form.dart | 8 + .../labels/view/widgets/label_item.dart | 1 - .../labels/view/widgets/label_tab_view.dart | 4 +- lib/features/landing/view/landing_page.dart | 10 +- .../view/widgets/mime_types_pie_chart.dart | 2 - .../view/linked_documents_page.dart | 2 - .../login/cubit/authentication_cubit.dart | 184 +++++--- .../login/cubit/authentication_state.dart | 70 ++- .../login/cubit/old_authentication_state.dart | 48 -- .../login/model/client_certificate.dart | 6 +- .../model/client_certificate_form_model.dart | 11 +- lib/features/login/view/add_account_page.dart | 260 +++++++---- lib/features/login/view/login_page.dart | 31 +- .../view/login_to_existing_account_page.dart | 66 +++ .../login/view/verify_identity_page.dart | 64 +++ .../client_certificate_form_field.dart | 45 +- .../server_address_form_field.dart | 6 +- .../user_credentials_form_field.dart | 25 +- .../login_pages/server_connection_page.dart | 170 ------- .../login_pages/server_login_page.dart | 85 ---- .../view/widgets/login_transition_page.dart | 34 ++ .../never_scrollable_scroll_behavior.dart | 8 - .../services/local_notification_service.dart | 5 +- .../cubit/document_paging_bloc_mixin.dart | 13 +- .../saved_view/view/saved_view_list.dart | 62 --- .../view/saved_view_loading_sliver_list.dart | 32 -- .../view/saved_view_details_page.dart | 102 ----- .../view/saved_view_preview.dart | 2 - .../settings/view/manage_accounts_page.dart | 102 ++--- .../view/pages/switching_accounts_page.dart | 29 -- .../widgets/language_selection_setting.dart | 7 +- ...ocument_prepraration_on_share_setting.dart | 6 +- .../sharing/logic/upload_queue_processor.dart | 73 --- .../sharing/model/share_intent_queue.dart | 105 ----- .../sharing/view/consumption_queue_view.dart | 3 +- ...e_shell.dart => event_listener_shell.dart} | 81 +++- .../tasks/model/pending_tasks_notifier.dart | 8 +- lib/helpers/file_helpers.dart | 3 - lib/helpers/image_helpers.dart | 38 -- lib/l10n/intl_ca.arb | 18 +- lib/l10n/intl_cs.arb | 18 +- lib/l10n/intl_de.arb | 18 +- lib/l10n/intl_en.arb | 18 +- lib/l10n/intl_es.arb | 18 +- lib/l10n/intl_fr.arb | 18 +- lib/l10n/intl_pl.arb | 18 +- lib/l10n/intl_ru.arb | 18 +- lib/l10n/intl_tr.arb | 18 +- lib/main.dart | 227 ++++----- lib/routes/navigation_keys.dart | 1 + lib/routes/routes.dart | 7 +- .../typed/branches/documents_route.dart | 9 +- lib/routes/typed/branches/labels_route.dart | 9 +- lib/routes/typed/branches/scanner_route.dart | 4 +- .../typed/branches/upload_queue_route.dart | 3 +- .../typed/shells/provider_shell_route.dart | 6 +- .../typed/top_level/add_account_route.dart | 80 ++++ .../typed/top_level/checking_login_route.dart | 23 - .../typed/top_level/logging_out_route.dart | 6 +- lib/routes/typed/top_level/login_route.dart | 127 +++++- .../typed/top_level/settings_route.dart | 2 +- .../top_level/switching_accounts_route.dart | 18 - .../top_level/verify_identity_route.dart | 19 - .../paperless_form_validation_exception.dart | 3 +- .../authentication_api_impl.dart | 8 +- .../paperless_documents_api.dart | 2 +- .../paperless_documents_api_impl.dart | 3 +- pubspec.yaml | 4 +- 102 files changed, 1512 insertions(+), 3090 deletions(-) delete mode 100644 lib/core/bloc/bloc_refresh_listenable.dart delete mode 100644 lib/core/bloc/document_status_cubit.dart delete mode 100644 lib/core/navigation/push_routes.dart delete mode 100644 lib/core/service/file_description.dart delete mode 100644 lib/core/service/status_service.dart delete mode 100644 lib/core/widgets/app_options_popup_menu.dart delete mode 100644 lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart delete mode 100644 lib/core/widgets/material/chips_input.dart delete mode 100644 lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart delete mode 100644 lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart delete mode 100644 lib/features/document_scan/logic/services/decode.isolate.dart delete mode 100644 lib/features/documents/view/widgets/new_items_loading_widget.dart delete mode 100644 lib/features/home/view/route_description.dart delete mode 100644 lib/features/home/view/widget/verify_identity_page.dart delete mode 100644 lib/features/labels/storage_path/view/widgets/storage_path_widget.dart delete mode 100644 lib/features/login/cubit/old_authentication_state.dart create mode 100644 lib/features/login/view/login_to_existing_account_page.dart create mode 100644 lib/features/login/view/verify_identity_page.dart delete mode 100644 lib/features/login/view/widgets/login_pages/server_connection_page.dart delete mode 100644 lib/features/login/view/widgets/login_pages/server_login_page.dart create mode 100644 lib/features/login/view/widgets/login_transition_page.dart delete mode 100644 lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart delete mode 100644 lib/features/saved_view/view/saved_view_list.dart delete mode 100644 lib/features/saved_view/view/saved_view_loading_sliver_list.dart delete mode 100644 lib/features/saved_view_details/view/saved_view_details_page.dart delete mode 100644 lib/features/settings/view/pages/switching_accounts_page.dart delete mode 100644 lib/features/sharing/logic/upload_queue_processor.dart delete mode 100644 lib/features/sharing/model/share_intent_queue.dart rename lib/features/sharing/view/widgets/{upload_queue_shell.dart => event_listener_shell.dart} (73%) delete mode 100644 lib/helpers/file_helpers.dart delete mode 100644 lib/helpers/image_helpers.dart create mode 100644 lib/routes/typed/top_level/add_account_route.dart delete mode 100644 lib/routes/typed/top_level/checking_login_route.dart delete mode 100644 lib/routes/typed/top_level/switching_accounts_route.dart delete mode 100644 lib/routes/typed/top_level/verify_identity_route.dart diff --git a/lib/core/bloc/bloc_refresh_listenable.dart b/lib/core/bloc/bloc_refresh_listenable.dart deleted file mode 100644 index f7b8067c..00000000 --- a/lib/core/bloc/bloc_refresh_listenable.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -class GoRouterRefreshStream extends ChangeNotifier { - GoRouterRefreshStream(Stream stream) { - notifyListeners(); - _subscription = stream.asBroadcastStream().listen( - (dynamic _) => notifyListeners(), - ); - } - - late final StreamSubscription _subscription; - - @override - void dispose() { - _subscription.cancel(); - super.dispose(); - } -} diff --git a/lib/core/bloc/document_status_cubit.dart b/lib/core/bloc/document_status_cubit.dart deleted file mode 100644 index 84121dd4..00000000 --- a/lib/core/bloc/document_status_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/core/model/document_processing_status.dart'; - -class DocumentStatusCubit extends Cubit { - DocumentStatusCubit() : super(null); - - void updateStatus(DocumentProcessingStatus? status) => emit(status); -} diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart deleted file mode 100644 index 0dfad7cf..00000000 --- a/lib/core/navigation/push_routes.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/user_repository.dart'; -import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; -import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart'; -import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:provider/provider.dart'; - -Future pushSavedViewDetailsRoute( - BuildContext context, { - required SavedView savedView, -}) { - final apiVersion = context.read(); - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: apiVersion), - if (context.watch().hasMultiUserSupport) - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - ], - builder: (_, child) { - return BlocProvider( - create: (context) => SavedViewDetailsCubit( - context.read(), - context.read(), - context.read(), - LocalUserAppState.current, - context.read(), - savedView: savedView, - ), - child: SavedViewDetailsPage( - onDelete: context.read().remove, - ), - ); - }, - ), - ), - ); -} - -Future pushBulkEditCorrespondentRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.correspondents, - selection: state.selection, - labelMapper: (document) => document.correspondent, - leadingIcon: const Icon(Icons.person_outline), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyCorrespondent, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditCorrespondentAssignMessage( - name, - count, - ); - }, - removeMessageBuilder: (int count) { - return S - .of(context)! - .bulkEditCorrespondentRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -Future pushBulkEditStoragePathRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.storagePaths, - selection: state.selection, - labelMapper: (document) => document.storagePath, - leadingIcon: const Icon(Icons.folder_outlined), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyStoragePath, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditStoragePathAssignMessage( - count, - name, - ); - }, - removeMessageBuilder: (int count) { - return S.of(context)!.bulkEditStoragePathRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -Future pushBulkEditTagsRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: Builder(builder: (context) { - return const FullscreenBulkEditTagsWidget(); - }), - ), - ), - ), - ); -} - -Future pushBulkEditDocumentTypeRoute(BuildContext context, - {required List selection}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.documentTypes, - selection: state.selection, - labelMapper: (document) => document.documentType, - leadingIcon: const Icon(Icons.description_outlined), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyDocumentType, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditDocumentTypeAssignMessage( - count, - name, - ); - }, - removeMessageBuilder: (int count) { - return S - .of(context)! - .bulkEditDocumentTypeRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -List _getRequiredBulkEditProviders(BuildContext context) { - return [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - ]; -} diff --git a/lib/core/notifier/document_changed_notifier.dart b/lib/core/notifier/document_changed_notifier.dart index 04a9781a..e1c2bba6 100644 --- a/lib/core/notifier/document_changed_notifier.dart +++ b/lib/core/notifier/document_changed_notifier.dart @@ -12,6 +12,10 @@ class DocumentChangedNotifier { final Map> _subscribers = {}; + Stream get $updated => _updated.asBroadcastStream(); + + Stream get $deleted => _deleted.asBroadcastStream(); + void notifyUpdated(DocumentModel updated) { debugPrint("Notifying updated document ${updated.id}"); _updated.add(updated); diff --git a/lib/core/service/file_description.dart b/lib/core/service/file_description.dart deleted file mode 100644 index 5b0a81ca..00000000 --- a/lib/core/service/file_description.dart +++ /dev/null @@ -1,20 +0,0 @@ -class FileDescription { - final String filename; - final String extension; - - FileDescription({ - required this.filename, - required this.extension, - }); - - factory FileDescription.fromPath(String path) { - final filename = path.split(RegExp(r"/")).last; - final fragments = filename.split("."); - final ext = fragments.removeLast(); - final name = fragments.join("."); - return FileDescription( - filename: name, - extension: ext, - ); - } -} diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 61d78320..32482a25 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; @@ -14,9 +13,6 @@ class FileService { String filename, ) async { final dir = await documentsDirectory; - if (dir == null) { - throw const PaperlessApiException.unknown(); //TODO: better handling - } File file = File("${dir.path}/$filename"); return file..writeAsBytes(bytes); } @@ -43,7 +39,7 @@ class FileService { static Future get temporaryDirectory => getTemporaryDirectory(); - static Future get documentsDirectory async { + static Future get documentsDirectory async { if (Platform.isAndroid) { return (await getExternalStorageDirectories( type: StorageDirectory.documents, diff --git a/lib/core/service/status_service.dart b/lib/core/service/status_service.dart deleted file mode 100644 index ea16beea..00000000 --- a/lib/core/service/status_service.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; -// import 'package:web_socket_channel/io.dart'; - -abstract class StatusService { - Future startListeningBeforeDocumentUpload( - String httpUrl, UserCredentials credentials, String documentFileName); -} - -class WebSocketStatusService implements StatusService { - late WebSocket? socket; - // late IOWebSocketChannel? _channel; - - WebSocketStatusService(); - - @override - Future startListeningBeforeDocumentUpload( - String httpUrl, - UserCredentials credentials, - String documentFileName, - ) async { - // socket = await WebSocket.connect( - // httpUrl.replaceFirst("http", "ws") + "/ws/status/", - // customClient: getIt(), - // headers: { - // 'Authorization': 'Token ${credentials.token}', - // }, - // ).catchError((_) { - // // Use long polling if connection could not be established - // }); - - // if (socket != null) { - // socket!.where(isNotNull).listen((event) { - // final status = DocumentProcessingStatus.fromJson(event); - // getIt().updateStatus(status); - // if (status.currentProgress == 100) { - // socket!.close(); - // } - // }); - // } - } -} - -class LongPollingStatusService implements StatusService { - final Dio client; - const LongPollingStatusService(this.client); - - @override - Future startListeningBeforeDocumentUpload( - String httpUrl, - UserCredentials credentials, - String documentFileName, - ) async { - // final today = DateTime.now(); - // bool consumptionFinished = false; - // int retryCount = 0; - - // getIt().updateStatus( - // DocumentProcessingStatus( - // currentProgress: 0, - // filename: documentFileName, - // maxProgress: 100, - // message: ProcessingMessage.new_file, - // status: ProcessingStatus.working, - // taskId: DocumentProcessingStatus.unknownTaskId, - // documentId: null, - // isApproximated: true, - // ), - // ); - - // do { - // final response = await httpClient.get( - // Uri.parse( - // '$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'), - // ); - // final data = await compute( - // PagedSearchResult.fromJson, - // PagedSearchResultJsonSerializer( - // jsonDecode(response.body), DocumentModel.fromJson), - // ); - // if (data.count > 0) { - // consumptionFinished = true; - // final docId = data.results[0].id; - // getIt().updateStatus( - // DocumentProcessingStatus( - // currentProgress: 100, - // filename: documentFileName, - // maxProgress: 100, - // message: ProcessingMessage.finished, - // status: ProcessingStatus.success, - // taskId: DocumentProcessingStatus.unknownTaskId, - // documentId: docId, - // isApproximated: true, - // ), - // ); - // return; - // } - // sleep(const Duration(seconds: 1)); - // } while (!consumptionFinished && retryCount < maxRetries); - } -} diff --git a/lib/core/widgets/app_options_popup_menu.dart b/lib/core/widgets/app_options_popup_menu.dart deleted file mode 100644 index 91b6beef..00000000 --- a/lib/core/widgets/app_options_popup_menu.dart +++ /dev/null @@ -1,218 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:paperless_mobile/constants.dart'; -// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -// import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; -// import 'package:paperless_mobile/features/settings/model/view_type.dart'; -// import 'package:paperless_mobile/features/settings/view/settings_page.dart'; -// import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -// import 'package:url_launcher/link.dart'; -// import 'package:url_launcher/url_launcher_string.dart'; - -// /// Declares selectable actions in menu. -// enum AppPopupMenuEntries { -// // Documents preview -// documentsSelectListView, -// documentsSelectGridView, -// // Generic actions -// openAboutThisAppDialog, -// reportBug, -// openSettings, -// // Adds a divider -// divider; -// } - -// class AppOptionsPopupMenu extends StatelessWidget { -// final List displayedActions; -// const AppOptionsPopupMenu({ -// super.key, -// required this.displayedActions, -// }); - -// @override -// Widget build(BuildContext context) { -// return PopupMenuButton( -// position: PopupMenuPosition.under, -// icon: const Icon(Icons.more_vert), -// onSelected: (action) { -// switch (action) { -// case AppPopupMenuEntries.documentsSelectListView: -// context.read().setViewType(ViewType.list); -// break; -// case AppPopupMenuEntries.documentsSelectGridView: -// context.read().setViewType(ViewType.grid); -// break; -// case AppPopupMenuEntries.openAboutThisAppDialog: -// _showAboutDialog(context); -// break; -// case AppPopupMenuEntries.openSettings: -// Navigator.of(context).push( -// MaterialPageRoute( -// builder: (context) => BlocProvider.value( -// value: context.read(), -// child: const SettingsPage(), -// ), -// ), -// ); -// break; -// case AppPopupMenuEntries.reportBug: -// launchUrlString( -// 'https://github.com/astubenbord/paperless-mobile/issues/new', -// ); -// break; -// default: -// break; -// } -// }, -// itemBuilder: _buildEntries, -// ); -// } - -// PopupMenuItem _buildReportBugTile(BuildContext context) { -// return PopupMenuItem( -// value: AppPopupMenuEntries.reportBug, -// padding: EdgeInsets.zero, -// child: ListTile( -// leading: const Icon(Icons.bug_report), -// title: Text(S.of(context)!.reportABug), -// ), -// ); -// } - -// PopupMenuItem _buildSettingsTile(BuildContext context) { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// value: AppPopupMenuEntries.openSettings, -// child: ListTile( -// leading: const Icon(Icons.settings_outlined), -// title: Text(S.of(context)!.settings), -// ), -// ); -// } - -// PopupMenuItem _buildAboutTile(BuildContext context) { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// value: AppPopupMenuEntries.openAboutThisAppDialog, -// child: ListTile( -// leading: const Icon(Icons.info_outline), -// title: Text(S.of(context)!.aboutThisApp), -// ), -// ); -// } - -// PopupMenuItem _buildListViewTile() { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// child: BlocBuilder( -// builder: (context, state) { -// return ListTile( -// leading: const Icon(Icons.list), -// title: const Text("List"), -// trailing: state.preferredViewType == ViewType.list -// ? const Icon(Icons.check) -// : null, -// ); -// }, -// ), -// value: AppPopupMenuEntries.documentsSelectListView, -// ); -// } - -// PopupMenuItem _buildGridViewTile() { -// return PopupMenuItem( -// value: AppPopupMenuEntries.documentsSelectGridView, -// padding: EdgeInsets.zero, -// child: BlocBuilder( -// builder: (context, state) { -// return ListTile( -// leading: const Icon(Icons.grid_view_rounded), -// title: const Text("Grid"), -// trailing: state.preferredViewType == ViewType.grid -// ? const Icon(Icons.check) -// : null, -// ); -// }, -// ), -// ); -// } - -// void _showAboutDialog(BuildContext context) { -// showAboutDialog( -// context: context, -// applicationIcon: const ImageIcon( -// AssetImage('assets/logos/paperless_logo_green.png'), -// ), -// applicationName: 'Paperless Mobile', -// applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber, -// children: [ -// Text(S.of(context)!.developedBy('Anton Stubenbord')), -// Link( -// uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'), -// builder: (context, followLink) => GestureDetector( -// onTap: followLink, -// child: Text( -// 'https://github.com/astubenbord/paperless-mobile', -// style: TextStyle(color: Theme.of(context).colorScheme.tertiary), -// ), -// ), -// ), -// const SizedBox(height: 16), -// Text( -// 'Credits', -// style: Theme.of(context).textTheme.titleMedium, -// ), -// _buildOnboardingImageCredits(), -// ], -// ); -// } - -// Widget _buildOnboardingImageCredits() { -// return Link( -// uri: Uri.parse( -// 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'), -// builder: (context, followLink) => Wrap( -// children: [ -// const Text('Onboarding images by '), -// GestureDetector( -// onTap: followLink, -// child: Text( -// 'pch.vector', -// style: TextStyle(color: Theme.of(context).colorScheme.tertiary), -// ), -// ), -// const Text(' on Freepik.') -// ], -// ), -// ); -// } - -// List> _buildEntries( -// BuildContext context) { -// List> items = []; -// for (final entry in displayedActions) { -// switch (entry) { -// case AppPopupMenuEntries.documentsSelectListView: -// items.add(_buildListViewTile()); -// break; -// case AppPopupMenuEntries.documentsSelectGridView: -// items.add(_buildGridViewTile()); -// break; -// case AppPopupMenuEntries.openAboutThisAppDialog: -// items.add(_buildAboutTile(context)); -// break; -// case AppPopupMenuEntries.reportBug: -// items.add(_buildReportBugTile(context)); -// break; -// case AppPopupMenuEntries.openSettings: -// items.add(_buildSettingsTile(context)); -// break; -// case AppPopupMenuEntries.divider: -// items.add(const PopupMenuDivider()); -// break; -// } -// } -// return items; -// } -// } diff --git a/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart b/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart deleted file mode 100644 index bbd8480e..00000000 --- a/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart +++ /dev/null @@ -1,430 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; - -typedef SelectionToTextTransformer = String Function(T suggestion); - -/// Text field that auto-completes user input from a list of items -class FormBuilderTypeAhead extends FormBuilderField { - /// Called with the search pattern to get the search suggestions. - /// - /// This callback must not be null. It is be called by the TypeAhead widget - /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) - /// of suggestions either synchronously, or asynchronously (as the result of a - /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). - /// Typically, the list of suggestions should not contain more than 4 or 5 - /// entries. These entries will then be provided to [itemBuilder] to display - /// the suggestions. - /// - /// Example: - /// ```dart - /// suggestionsCallback: (pattern) async { - /// return await _getSuggestions(pattern); - /// } - /// ``` - final SuggestionsCallback suggestionsCallback; - - /// Called when a suggestion is tapped. - /// - /// This callback must not be null. It is called by the TypeAhead widget and - /// provided with the value of the tapped suggestion. - /// - /// For example, you might want to navigate to a specific view when the user - /// tabs a suggestion: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// Navigator.of(context).push(MaterialPageRoute( - /// builder: (context) => SearchResult( - /// searchItem: suggestion - /// ) - /// )); - /// } - /// ``` - /// - /// Or to set the value of the text field: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// _controller.text = suggestion['name']; - /// } - /// ``` - final SuggestionSelectionCallback? onSuggestionSelected; - - /// Called for each suggestion returned by [suggestionsCallback] to build the - /// corresponding widget. - /// - /// This callback must not be null. It is called by the TypeAhead widget for - /// each suggestion, and expected to build a widget to display this - /// suggestion's info. For example: - /// - /// ```dart - /// itemBuilder: (context, suggestion) { - /// return ListTile( - /// title: Text(suggestion['name']), - /// subtitle: Text('USD' + suggestion['price'].toString()) - /// ); - /// } - /// ``` - final ItemBuilder itemBuilder; - - /// The decoration of the material sheet that contains the suggestions. - /// - /// If null, default decoration with an elevation of 4.0 is used - final SuggestionsBoxDecoration suggestionsBoxDecoration; - - /// Used to control the `_SuggestionsBox`. Allows manual control to - /// open, close, toggle, or resize the `_SuggestionsBox`. - final SuggestionsBoxController? suggestionsBoxController; - - /// The duration to wait after the user stops typing before calling - /// [suggestionsCallback] - /// - /// This is useful, because, if not set, a request for suggestions will be - /// sent for every character that the user types. - /// - /// This duration is set by default to 300 milliseconds - final Duration debounceDuration; - - /// Called when waiting for [suggestionsCallback] to return. - /// - /// It is expected to return a widget to display while waiting. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('Loading...'); - /// } - /// ``` - /// - /// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown - final WidgetBuilder? loadingBuilder; - - /// Called when [suggestionsCallback] returns an empty array. - /// - /// It is expected to return a widget to display when no suggestions are - /// available. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('No Items Found!'); - /// } - /// ``` - /// - /// If not specified, a simple text is shown - final WidgetBuilder? noItemsFoundBuilder; - - /// Called when [suggestionsCallback] throws an exception. - /// - /// It is called with the error object, and expected to return a widget to - /// display when an exception is thrown - /// For example: - /// ```dart - /// (BuildContext context, error) { - /// return Text('$error'); - /// } - /// ``` - /// - /// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html) - final ErrorBuilder? errorBuilder; - - /// Called to display animations when [suggestionsCallback] returns suggestions - /// - /// It is provided with the suggestions box instance and the animation - /// controller, and expected to return some animation that uses the controller - /// to display the suggestion box. - /// - /// For example: - /// ```dart - /// transitionBuilder: (context, suggestionsBox, animationController) { - /// return FadeTransition( - /// child: suggestionsBox, - /// opacity: CurvedAnimation( - /// parent: animationController, - /// curve: Curves.fastOutSlowIn - /// ), - /// ); - /// } - /// ``` - /// This argument is best used with [animationDuration] and [animationStart] - /// to fully control the animation. - /// - /// To fully remove the animation, just return `suggestionsBox` - /// - /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. - final AnimationTransitionBuilder? transitionBuilder; - - /// The duration that [transitionBuilder] animation takes. - /// - /// This argument is best used with [transitionBuilder] and [animationStart] - /// to fully control the animation. - /// - /// Defaults to 500 milliseconds. - final Duration animationDuration; - - /// Determine the [SuggestionBox]'s direction. - /// - /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] - /// and the [_SuggestionsList] will grow **down**. - /// - /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] - /// and the [_SuggestionsList] will grow **up**. - /// - /// [AxisDirection.left] and [AxisDirection.right] are not allowed. - final AxisDirection direction; - - /// The value at which the [transitionBuilder] animation starts. - /// - /// This argument is best used with [transitionBuilder] and [animationDuration] - /// to fully control the animation. - /// - /// Defaults to 0.25. - final double animationStart; - - /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) - /// that the TypeAhead widget displays - final TextFieldConfiguration textFieldConfiguration; - - /// How far below the text field should the suggestions box be - /// - /// Defaults to 5.0 - final double suggestionsBoxVerticalOffset; - - /// If set to true, suggestions will be fetched immediately when the field is - /// added to the view. - /// - /// But the suggestions box will only be shown when the field receives focus. - /// To make the field receive focus immediately, you can set the `autofocus` - /// property in the [textFieldConfiguration] to true - /// - /// Defaults to false - final bool getImmediateSuggestions; - - /// If set to true, no loading box will be shown while suggestions are - /// being fetched. [loadingBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnLoading; - - /// If set to true, nothing will be shown if there are no results. - /// [noItemsFoundBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnEmpty; - - /// If set to true, nothing will be shown if there is an error. - /// [errorBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnError; - - /// If set to false, the suggestions box will stay opened after - /// the keyboard is closed. - /// - /// Defaults to true. - final bool hideSuggestionsOnKeyboardHide; - - /// If set to false, the suggestions box will show a circular - /// progress indicator when retrieving suggestions. - /// - /// Defaults to true. - final bool keepSuggestionsOnLoading; - - /// If set to true, the suggestions box will remain opened even after - /// selecting a suggestion. - /// - /// Note that if this is enabled, the only way - /// to close the suggestions box is either manually via the - /// `SuggestionsBoxController` or when the user closes the software - /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users - /// with a physical keyboard will be unable to close the - /// box without a manual way via `SuggestionsBoxController`. - /// - /// Defaults to false. - final bool keepSuggestionsOnSuggestionSelected; - - /// If set to true, in the case where the suggestions box has less than - /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis - /// will be temporarily flipped if there's more room available in the opposite - /// direction. - /// - /// Defaults to false - final bool autoFlipDirection; - - final SelectionToTextTransformer? selectionToTextTransformer; - - /// Controls the text being edited. - /// - /// If null, this widget will create its own [TextEditingController]. - final TextEditingController? controller; - - final bool hideKeyboard; - - final ScrollController? scrollController; - - /// Creates text field that auto-completes user input from a list of items - FormBuilderTypeAhead({ - Key? key, - //From Super - AutovalidateMode autovalidateMode = AutovalidateMode.disabled, - bool enabled = true, - FocusNode? focusNode, - FormFieldSetter? onSaved, - FormFieldValidator? validator, - InputDecoration decoration = const InputDecoration(), - required String name, - required this.itemBuilder, - required this.suggestionsCallback, - T? initialValue, - ValueChanged? onChanged, - ValueTransformer? valueTransformer, - VoidCallback? onReset, - this.animationDuration = const Duration(milliseconds: 500), - this.animationStart = 0.25, - this.autoFlipDirection = false, - this.controller, - this.debounceDuration = const Duration(milliseconds: 300), - this.direction = AxisDirection.down, - this.errorBuilder, - this.getImmediateSuggestions = false, - this.hideKeyboard = false, - this.hideOnEmpty = false, - this.hideOnError = false, - this.hideOnLoading = false, - this.hideSuggestionsOnKeyboardHide = true, - this.keepSuggestionsOnLoading = true, - this.keepSuggestionsOnSuggestionSelected = false, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.onSuggestionSelected, - this.scrollController, - this.selectionToTextTransformer, - this.suggestionsBoxController, - this.suggestionsBoxDecoration = const SuggestionsBoxDecoration(), - this.suggestionsBoxVerticalOffset = 5.0, - this.textFieldConfiguration = const TextFieldConfiguration(), - this.transitionBuilder, - }) : assert(T == String || selectionToTextTransformer != null), - super( - key: key, - initialValue: initialValue, - name: name, - validator: validator, - valueTransformer: valueTransformer, - onChanged: onChanged, - autovalidateMode: autovalidateMode, - onSaved: onSaved, - enabled: enabled, - onReset: onReset, - decoration: decoration, - focusNode: focusNode, - builder: (FormFieldState field) { - final state = field as FormBuilderTypeAheadState; - final theme = Theme.of(state.context); - - return TypeAheadField( - textFieldConfiguration: textFieldConfiguration.copyWith( - enabled: state.enabled, - controller: state._typeAheadController, - style: state.enabled - ? textFieldConfiguration.style - : theme.textTheme.titleMedium!.copyWith( - color: theme.disabledColor, - ), - focusNode: state.effectiveFocusNode, - decoration: state.decoration, - ), - // TODO HACK to satisfy strictness - suggestionsCallback: suggestionsCallback, - itemBuilder: itemBuilder, - transitionBuilder: (context, suggestionsBox, controller) => - suggestionsBox, - onSuggestionSelected: (T suggestion) { - state.didChange(suggestion); - onSuggestionSelected?.call(suggestion); - }, - getImmediateSuggestions: getImmediateSuggestions, - errorBuilder: errorBuilder, - noItemsFoundBuilder: noItemsFoundBuilder, - loadingBuilder: loadingBuilder, - debounceDuration: debounceDuration, - suggestionsBoxDecoration: suggestionsBoxDecoration, - suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, - animationDuration: animationDuration, - animationStart: animationStart, - direction: direction, - hideOnLoading: hideOnLoading, - hideOnEmpty: hideOnEmpty, - hideOnError: hideOnError, - hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, - keepSuggestionsOnLoading: keepSuggestionsOnLoading, - autoFlipDirection: autoFlipDirection, - suggestionsBoxController: suggestionsBoxController, - keepSuggestionsOnSuggestionSelected: - keepSuggestionsOnSuggestionSelected, - hideKeyboard: hideKeyboard, - scrollController: scrollController, - ); - }, - ); - - @override - FormBuilderTypeAheadState createState() => FormBuilderTypeAheadState(); -} - -class FormBuilderTypeAheadState - extends FormBuilderFieldState, T> { - late TextEditingController _typeAheadController; - - @override - void initState() { - super.initState(); - _typeAheadController = widget.controller ?? - TextEditingController(text: _getTextString(initialValue)); - // _typeAheadController.addListener(_handleControllerChanged); - } - - // void _handleControllerChanged() { - // Suppress changes that originated from within this class. - // - // In the case where a controller has been passed in to this widget, we - // register this change listener. In these cases, we'll also receive change - // notifications for changes originating from within this class -- for - // example, the reset() method. In such cases, the FormField value will - // already have been set. - // if (_typeAheadController.text != value) { - // didChange(_typeAheadController.text as T); - // } - // } - - @override - void didChange(T? value) { - super.didChange(value); - var text = _getTextString(value); - - if (_typeAheadController.text != text) { - _typeAheadController.text = text; - } - } - - @override - void dispose() { - // Dispose the _typeAheadController when initState created it - super.dispose(); - _typeAheadController.dispose(); - } - - @override - void reset() { - super.reset(); - - _typeAheadController.text = _getTextString(initialValue); - } - - String _getTextString(T? value) { - var text = value == null - ? '' - : widget.selectionToTextTransformer != null - ? widget.selectionToTextTransformer!(value) - : value.toString(); - - return text; - } -} diff --git a/lib/core/widgets/material/chips_input.dart b/lib/core/widgets/material/chips_input.dart deleted file mode 100644 index 7369a0ce..00000000 --- a/lib/core/widgets/material/chips_input.dart +++ /dev/null @@ -1,288 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 Simon Lightfoot -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -typedef ChipsInputSuggestions = Future> Function(String query); -typedef ChipSelected = void Function(T data, bool selected); -typedef ChipsBuilder = Widget Function( - BuildContext context, ChipsInputState state, T data); - -class ChipsInput extends StatefulWidget { - const ChipsInput({ - super.key, - this.decoration = const InputDecoration(), - required this.chipBuilder, - required this.suggestionBuilder, - required this.findSuggestions, - required this.onChanged, - this.onChipTapped, - }); - - final InputDecoration decoration; - final ChipsInputSuggestions findSuggestions; - final ValueChanged> onChanged; - final ValueChanged? onChipTapped; - final ChipsBuilder chipBuilder; - final ChipsBuilder suggestionBuilder; - - @override - ChipsInputState createState() => ChipsInputState(); -} - -class ChipsInputState extends State> { - static const kObjectReplacementChar = 0xFFFC; - - Set _chips = {}; - List _suggestions = []; - int _searchId = 0; - - FocusNode _focusNode = FocusNode(); - TextEditingValue _value = const TextEditingValue(); - TextInputConnection? _connection; - - String get text { - return String.fromCharCodes( - _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar), - ); - } - - TextEditingValue get currentTextEditingValue => _value; - - bool get _hasInputConnection => - _connection != null && (_connection?.attached ?? false); - - void requestKeyboard() { - if (_focusNode.hasFocus) { - _openInputConnection(); - } else { - FocusScope.of(context).requestFocus(_focusNode); - } - } - - void selectSuggestion(T data) { - setState(() { - _chips.add(data); - _updateTextInputState(); - _suggestions = []; - }); - widget.onChanged(_chips.toList(growable: false)); - } - - void deleteChip(T data) { - setState(() { - _chips.remove(data); - _updateTextInputState(); - }); - widget.onChanged(_chips.toList(growable: false)); - } - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(); - _focusNode.addListener(_onFocusChanged); - } - - void _onFocusChanged() { - if (_focusNode.hasFocus) { - _openInputConnection(); - } else { - _closeInputConnectionIfNeeded(); - } - setState(() { - // rebuild so that _TextCursor is hidden. - }); - } - - @override - void dispose() { - _focusNode.dispose(); - _closeInputConnectionIfNeeded(); - super.dispose(); - } - - void _openInputConnection() { - if (!_hasInputConnection) { - _connection?.setEditingState(_value); - } - _connection?.show(); - } - - void _closeInputConnectionIfNeeded() { - if (_hasInputConnection) { - _connection?.close(); - _connection = null; - } - } - - @override - Widget build(BuildContext context) { - var chipsChildren = _chips - .map( - (data) => widget.chipBuilder(context, this, data), - ) - .toList(); - - final theme = Theme.of(context); - - chipsChildren.add( - SizedBox( - height: 32.0, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - text, - style: theme.textTheme.bodyLarge?.copyWith( - height: 1.5, - ), - ), - _TextCaret( - resumed: _focusNode.hasFocus, - ), - ], - ), - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - //mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: requestKeyboard, - child: InputDecorator( - decoration: widget.decoration, - isFocused: _focusNode.hasFocus, - isEmpty: _value.text.isEmpty, - child: Wrap( - children: chipsChildren, - spacing: 4.0, - runSpacing: 4.0, - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: _suggestions.length, - itemBuilder: (BuildContext context, int index) { - return widget.suggestionBuilder( - context, this, _suggestions[index]); - }, - ), - ), - ], - ); - } - - void updateEditingValue(TextEditingValue value) { - final oldCount = _countReplacements(_value); - final newCount = _countReplacements(value); - setState(() { - if (newCount < oldCount) { - _chips = Set.from(_chips.take(newCount)); - } - _value = value; - }); - _onSearchChanged(text); - } - - int _countReplacements(TextEditingValue value) { - return value.text.codeUnits - .where((ch) => ch == kObjectReplacementChar) - .length; - } - - void _updateTextInputState() { - final text = - String.fromCharCodes(_chips.map((_) => kObjectReplacementChar)); - _value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - composing: TextRange(start: 0, end: text.length), - ); - _connection?.setEditingState(_value); - } - - void _onSearchChanged(String value) async { - final localId = ++_searchId; - final results = await widget.findSuggestions(value); - if (_searchId == localId && mounted) { - setState(() => _suggestions = results - .where((profile) => !_chips.contains(profile)) - .toList(growable: false)); - } - } -} - -class _TextCaret extends StatefulWidget { - const _TextCaret({ - this.resumed = false, - }); - - final bool resumed; - - @override - _TextCursorState createState() => _TextCursorState(); -} - -class _TextCursorState extends State<_TextCaret> - with SingleTickerProviderStateMixin { - bool _displayed = false; - late Timer _timer; - - @override - void initState() { - super.initState(); - } - - void _onTimer(Timer timer) { - setState(() => _displayed = !_displayed); - } - - @override - void dispose() { - _timer.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return FractionallySizedBox( - heightFactor: 0.7, - child: Opacity( - opacity: _displayed && widget.resumed ? 1.0 : 0.0, - child: Container( - width: 2.0, - color: theme.primaryColor, - ), - ), - ); - } -} diff --git a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart b/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart deleted file mode 100644 index 9f740b8b..00000000 --- a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; -import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -typedef LabelOptionsSelector = Map Function( - DocumentBulkActionState state); - -class BulkEditLabelBottomSheet extends StatefulWidget { - final String title; - final String formFieldLabel; - final Widget formFieldPrefixIcon; - final LabelOptionsSelector availableOptionsSelector; - final void Function(int? selectedId) onSubmit; - final int? initialValue; - final bool canCreateNewLabel; - - const BulkEditLabelBottomSheet({ - super.key, - required this.title, - required this.formFieldLabel, - required this.formFieldPrefixIcon, - required this.availableOptionsSelector, - required this.onSubmit, - this.initialValue, - required this.canCreateNewLabel, - }); - - @override - State> createState() => - _BulkEditLabelBottomSheetState(); -} - -class _BulkEditLabelBottomSheetState - extends State> { - final _formKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: - EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: BlocBuilder( - builder: (context, state) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.title, - style: Theme.of(context).textTheme.titleLarge, - ).paddedOnly(bottom: 24), - FormBuilder( - key: _formKey, - child: LabelFormField( - initialValue: widget.initialValue != null - ? IdQueryParameter.fromId(widget.initialValue!) - : const IdQueryParameter.unset(), - canCreateNewLabel: widget.canCreateNewLabel, - name: "labelFormField", - options: widget.availableOptionsSelector(state), - labelText: widget.formFieldLabel, - prefixIcon: widget.formFieldPrefixIcon, - allowSelectUnassigned: true, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const DialogCancelButton(), - const SizedBox(width: 16), - FilledButton( - onPressed: () { - if (_formKey.currentState?.saveAndValidate() ?? - false) { - final value = _formKey.currentState - ?.getRawValue('labelFormField') - as IdQueryParameter?; - widget.onSubmit(value?.maybeWhen( - fromId: (id) => id, orElse: () => null)); - } - }, - child: Text(S.of(context)!.apply), - ), - ], - ).padded(8), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart b/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart deleted file mode 100644 index 8bde625c..00000000 --- a/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart +++ /dev/null @@ -1,30 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter/src/widgets/framework.dart'; -// import 'package:flutter/src/widgets/placeholder.dart'; - -// class LabelBulkSelectionWidget extends StatelessWidget { -// final int labelId; -// final String title; -// final bool selected; -// final bool excluded; -// final Widget Function(int id) leadingWidgetBuilder; -// final void Function(int id) onSelected; -// final void Function(int id) onUnselected; -// final void Function(int id) onRemoved; - -// const LabelBulkSelectionWidget({ -// super.key, -// required this.labelId, -// required this.title, -// required this.leadingWidgetBuilder, -// required this.onSelected, -// required this.onUnselected, -// required this.onRemoved, -// }); -// @override -// Widget build(BuildContext context) { -// return ListTile( -// title: Text(title), -// ); -// } -// } diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index cf09e950..27798636 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,16 +6,14 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; import 'package:cross_file/cross_file.dart'; - +import 'package:path/path.dart' as p; part 'document_details_cubit.freezed.dart'; part 'document_details_state.dart'; @@ -94,11 +92,9 @@ class DocumentDetailsCubit extends Cubit { if (state.metaData == null) { await loadMetaData(); } - final desc = FileDescription.fromPath( - state.metaData!.mediaFilename.replaceAll("/", " "), - ); + final filePath = state.metaData!.mediaFilename.replaceAll("/", " "); - final fileName = "${desc.filename}.pdf"; + final fileName = "${p.basenameWithoutExtension(filePath)}.pdf"; final file = File("${cacheDir.path}/$fileName"); if (!file.existsSync()) { @@ -126,50 +122,58 @@ class DocumentDetailsCubit extends Cubit { if (state.metaData == null) { await loadMetaData(); } - String filePath = _buildDownloadFilePath( + String targetPath = _buildDownloadFilePath( downloadOriginal, await FileService.downloadsDirectory, ); - final desc = FileDescription.fromPath( - state.metaData!.mediaFilename - .replaceAll("/", " "), // Flatten directory structure - ); - if (!File(filePath).existsSync()) { - File(filePath).createSync(); + + if (!await File(targetPath).exists()) { + await File(targetPath).create(); } else { - return _notificationService.notifyFileDownload( + await _notificationService.notifyFileDownload( document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, + filename: p.basename(targetPath), + filePath: targetPath, finished: true, locale: locale, userId: userId, ); } - await _notificationService.notifyFileDownload( - document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, - finished: false, - locale: locale, - userId: userId, - ); + // await _notificationService.notifyFileDownload( + // document: state.document, + // filename: p.basename(targetPath), + // filePath: targetPath, + // finished: false, + // locale: locale, + // userId: userId, + // ); await _api.downloadToFile( state.document, - filePath, + targetPath, original: downloadOriginal, + onProgressChanged: (progress) { + _notificationService.notifyFileDownload( + document: state.document, + filename: p.basename(targetPath), + filePath: targetPath, + finished: true, + locale: locale, + userId: userId, + progress: progress, + ); + }, ); await _notificationService.notifyFileDownload( document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, + filename: p.basename(targetPath), + filePath: targetPath, finished: true, locale: locale, userId: userId, ); - debugPrint("Downloaded file to $filePath"); + debugPrint("Downloaded file to $targetPath"); } Future shareDocument({bool shareOriginal = false}) async { @@ -220,12 +224,9 @@ class DocumentDetailsCubit extends Cubit { } String _buildDownloadFilePath(bool original, Directory dir) { - final description = FileDescription.fromPath( - state.metaData!.mediaFilename - .replaceAll("/", " "), // Flatten directory structure - ); - final extension = original ? description.extension : 'pdf'; - return "${dir.path}/${description.filename}.$extension"; + final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " "); + final extension = original ? p.extension(normalizedPath) : '.pdf'; + return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension"; } @override 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 b3a1d190..2ad12d6b 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,4 +1,3 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -48,7 +47,7 @@ class _DocumentDetailsPageState extends State { Widget build(BuildContext context) { final hasMultiUserSupport = context.watch().hasMultiUserSupport; - final tabLength = 4 + (hasMultiUserSupport ? 1 : 0); + final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0); return WillPopScope( onWillPop: () async { Navigator.of(context) @@ -86,51 +85,52 @@ class _DocumentDetailsPageState extends State { collapsedHeight: kToolbarHeight, expandedHeight: 250.0, flexibleSpace: FlexibleSpaceBar( - background: Stack( - alignment: Alignment.topCenter, - children: [ - BlocBuilder( - builder: (context, state) { - return Positioned.fill( - child: GestureDetector( - onTap: () { - DocumentPreviewRoute($extra: state.document) - .push(context); - }, - child: DocumentPreview( - document: state.document, - fit: BoxFit.cover, + background: BlocBuilder( + builder: (context, state) { + return Hero( + tag: "thumb_${state.document.id}", + child: GestureDetector( + onTap: () { + DocumentPreviewRoute($extra: state.document) + .push(context); + }, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: DocumentPreview( + enableHero: false, + document: state.document, + fit: BoxFit.cover, + ), ), - ), - ); - }, - ), - // Positioned.fill( - // top: -kToolbarHeight, - // child: DecoratedBox( - // decoration: BoxDecoration( - // gradient: LinearGradient( - // colors: [ - // Theme.of(context) - // .colorScheme - // .background - // .withOpacity(0.8), - // Theme.of(context) - // .colorScheme - // .background - // .withOpacity(0.5), - // Colors.transparent, - // Colors.transparent, - // Colors.transparent, - // ], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), - // ), - // ), - // ), - ], + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: [0.2, 0.4], + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0.6), + Theme.of(context) + .colorScheme + .background + .withOpacity(0.3), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ], + ), + ), + ); + }, ), ), bottom: ColoredTabBar( @@ -177,7 +177,7 @@ class _DocumentDetailsPageState extends State { ), ), ), - if (hasMultiUserSupport) + if (hasMultiUserSupport && false) Tab( child: Text( "Permissions", @@ -266,7 +266,7 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (hasMultiUserSupport) + if (hasMultiUserSupport && false) CustomScrollView( controller: _pagingScrollController, slivers: [ @@ -406,7 +406,7 @@ class _DocumentDetailsPageState extends State { if (delete) { try { await context.read().delete(document); - showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); + // showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } finally { diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 57438056..f974eb74 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -40,7 +40,6 @@ class _DocumentEditPageState extends State { static const fkContent = 'content'; final GlobalKey _formKey = GlobalKey(); - bool _isSubmitLoading = false; @override Widget build(BuildContext context) { @@ -314,18 +313,13 @@ class _DocumentEditPageState extends State { tags: (values[fkTags] as IdsTagsQuery?)?.include, content: values[fkContent], ); - setState(() { - _isSubmitLoading = true; - }); + try { await context.read().updateDocument(mergedDocument); showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } finally { - setState(() { - _isSubmitLoading = false; - }); context.pop(); } } diff --git a/lib/features/document_scan/logic/services/decode.isolate.dart b/lib/features/document_scan/logic/services/decode.isolate.dart deleted file mode 100644 index 91196be6..00000000 --- a/lib/features/document_scan/logic/services/decode.isolate.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; - -import 'package:image/image.dart' as im; - -typedef ImageOperationCallback = im.Image Function(im.Image); - -class DecodeParam { - final File file; - final SendPort sendPort; - final im.Image Function(im.Image) imageOperation; - DecodeParam(this.file, this.sendPort, this.imageOperation); -} - -void decodeIsolate(DecodeParam param) { - // Read an image from file (webp in this case). - // decodeImage will identify the format of the image and use the appropriate - // decoder. - var image = im.decodeImage(param.file.readAsBytesSync())!; - // Resize the image to a 120x? thumbnail (maintaining the aspect ratio). - var processed = param.imageOperation(image); - param.sendPort.send(processed); -} - -// Decode and process an image file in a separate thread (isolate) to avoid -// stalling the main UI thread. -Future processImage( - File file, - ImageOperationCallback imageOperation, -) async { - var receivePort = ReceivePort(); - - await Isolate.spawn( - decodeIsolate, - DecodeParam( - file, - receivePort.sendPort, - imageOperation, - )); - - var image = await receivePort.first as im.Image; - - return file.writeAsBytes(im.encodePng(image)); -} diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index bda703ba..729c8c04 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -13,7 +13,6 @@ import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; @@ -22,7 +21,6 @@ import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_ima import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; -import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -261,12 +259,12 @@ class _ScannerPageState extends State $extra: file.bytes, fileExtension: file.extension, ).push(context); - if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) { + if (uploadResult?.success ?? false) { // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); - context - .read() - .listenToTaskChanges(uploadResult!.taskId!); + // context + // .read() + // .listenToTaskChanges(uploadResult!.taskId!); } } @@ -350,17 +348,17 @@ class _ScannerPageState extends State void _onUploadFromFilesystem() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, - allowedExtensions: supportedFileExtensions, + allowedExtensions: + supportedFileExtensions.map((e) => e.replaceAll(".", "")).toList(), withData: true, allowMultiple: false, ); if (result?.files.single.path != null) { final path = result!.files.single.path!; - final fileDescription = FileDescription.fromPath(path); + final extension = p.extension(path); + final filename = p.basenameWithoutExtension(path); File file = File(path); - if (!supportedFileExtensions.contains( - fileDescription.extension.toLowerCase(), - )) { + if (!supportedFileExtensions.contains(extension.toLowerCase())) { showErrorMessage( context, const PaperlessApiException(ErrorCode.unsupportedFileFormat), @@ -369,10 +367,15 @@ class _ScannerPageState extends State } DocumentUploadRoute( $extra: file.readAsBytesSync(), - filename: fileDescription.filename, - title: fileDescription.filename, - fileExtension: fileDescription.extension, - ).push(context); + filename: filename, + title: filename, + fileExtension: extension, + ).push(context); + // if (uploadResult.success && uploadResult.taskId != null) { + // context + // .read() + // .listenToTaskChanges(uploadResult.taskId!); + // } } } diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart index 09e4ffc4..657475fc 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -16,6 +16,8 @@ class DocumentSearchCubit extends Cubit with DocumentPagingBlocMixin { @override final PaperlessDocumentsApi api; + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -25,6 +27,7 @@ class DocumentSearchCubit extends Cubit this.api, this.notifier, this._userAppState, + this.connectivityStatusService, ) : super( DocumentSearchState( searchHistory: _userAppState.documentSearchHistory), @@ -120,9 +123,4 @@ class DocumentSearchCubit extends Cubit @override Future onFilterUpdated(DocumentFilter filter) async {} - - @override - // TODO: implement connectivityStatusService - ConnectivityStatusService get connectivityStatusService => - throw UnimplementedError(); } diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index 3bdd755d..bbc3bd52 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -8,6 +8,8 @@ import 'package:paperless_mobile/features/document_search/cubit/document_search_ import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -51,7 +53,21 @@ class _DocumentSearchBarState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.menu), + icon: ListenableBuilder( + listenable: + context.read(), + builder: (context, child) { + return Badge( + isLabelVisible: context + .read() + .pendingFiles + .isNotEmpty, + child: const Icon(Icons.menu), + backgroundColor: Colors.red, + smallSize: 8, + ); + }, + ), onPressed: Scaffold.of(context).openDrawer, ), Flexible( @@ -81,6 +97,7 @@ class _DocumentSearchBarState extends State { context.read(), Hive.box(HiveBoxes.localUserAppState) .get(context.read().id)!, + context.read(), ), child: const DocumentSearchPage(), ); @@ -95,10 +112,7 @@ class _DocumentSearchBarState extends State { onPressed: () { showDialog( context: context, - builder: (_) => Provider.value( - value: context.read(), - child: const ManageAccountsPage(), - ), + builder: (_) => const ManageAccountsPage(), ); }, ); diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index f0ecff15..8cea3d9a 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; @@ -9,7 +8,6 @@ import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:provider/provider.dart'; -import 'package:sliver_tools/sliver_tools.dart'; class SliverSearchBar extends StatelessWidget { final bool floating; @@ -24,10 +22,8 @@ class SliverSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (context.watch().paperlessUser.canViewDocuments) { - return SliverAppBar( + return const SliverAppBar( titleSpacing: 8, automaticallyImplyLeading: false, title: DocumentSearchBar(), diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index e1d5bec8..86019a9f 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -6,12 +6,13 @@ import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; part 'document_upload_state.dart'; class DocumentUploadCubit extends Cubit { final PaperlessDocumentsApi _documentApi; - + final PendingTasksNotifier _tasksNotifier; final LabelRepository _labelRepository; final ConnectivityStatusService _connectivityStatusService; @@ -19,6 +20,7 @@ class DocumentUploadCubit extends Cubit { this._labelRepository, this._documentApi, this._connectivityStatusService, + this._tasksNotifier, ) : super(const DocumentUploadState()) { _labelRepository.addListener( this, @@ -43,7 +45,7 @@ class DocumentUploadCubit extends Cubit { DateTime? createdAt, int? asn, }) async { - return await _documentApi.create( + final taskId = await _documentApi.create( bytes, filename: filename, title: title, @@ -53,6 +55,10 @@ class DocumentUploadCubit extends Cubit { createdAt: createdAt, asn: asn, ); + if (taskId != null) { + _tasksNotifier.listenToTaskChanges(taskId); + } + return taskId; } @override diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index cb9ee110..a2113d52 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; -import 'package:image/image.dart' as img; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -24,7 +23,6 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_fie import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:provider/provider.dart'; diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index d6a1e3a9..9689a44f 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -247,8 +247,13 @@ class _DocumentsPageState extends State { resizeToAvoidBottomInset: true, body: WillPopScope( onWillPop: () async { - if (context.read().state.selection.isNotEmpty) { - context.read().resetSelection(); + final cubit = context.read(); + if (cubit.state.selection.isNotEmpty) { + cubit.resetSelection(); + return false; + } + if (cubit.state.filter.appliedFiltersCount > 0 || cubit.state.filter.selectedView != null) { + await _onResetFilter(); return false; } return true; diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index f99a1397..623e8645 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -31,16 +31,19 @@ class DocumentPreview extends StatelessWidget { Widget build(BuildContext context) { return ConnectivityAwareActionWrapper( child: GestureDetector( + behavior: HitTestBehavior.translucent, onTap: isClickable ? () => DocumentPreviewRoute($extra: document).push(context) : null, - child: HeroMode( - enabled: enableHero, - child: Hero( - tag: "thumb_${document.id}", - child: _buildPreview(context), - ), - ), + child: Builder(builder: (context) { + if (enableHero) { + return Hero( + tag: "thumb_${document.id}", + child: _buildPreview(context), + ); + } + return _buildPreview(context); + }), ), ); } diff --git a/lib/features/documents/view/widgets/new_items_loading_widget.dart b/lib/features/documents/view/widgets/new_items_loading_widget.dart deleted file mode 100644 index 042f6923..00000000 --- a/lib/features/documents/view/widgets/new_items_loading_widget.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; - -class NewItemsLoadingWidget extends StatelessWidget { - const NewItemsLoadingWidget({super.key}); - - @override - Widget build(BuildContext context) { - return Center(child: const CircularProgressIndicator().padded()); - } -} diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index 191d9222..8cdb5afc 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -6,7 +6,6 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; enum DateRangeSelection { before, after } diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 4a01d1ed..40bc2f05 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -17,14 +16,9 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; -import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:provider/provider.dart'; class HomeShellWidget extends StatelessWidget { @@ -52,16 +46,16 @@ class HomeShellWidget extends StatelessWidget { return GlobalSettingsBuilder( builder: (context, settings) { final currentUserId = settings.loggedInUserId; - if (currentUserId == null) { - // This is currently the case (only for a few ms) when the current user logs out of the app. - return const SizedBox.shrink(); - } final apiVersion = ApiVersion(paperlessApiVersion); return ValueListenableBuilder( valueListenable: - Hive.box(HiveBoxes.localUserAccount) - .listenable(keys: [currentUserId]), + Hive.localUserAccountBox.listenable(keys: [currentUserId]), builder: (context, box, _) { + if (currentUserId == null) { + //This only happens during logout... + //TODO: Find way so this does not occur anymore + return SizedBox.shrink(); + } final currentLocalUser = box.get(currentUserId)!; return MultiProvider( key: ValueKey(currentUserId), @@ -181,9 +175,7 @@ class HomeShellWidget extends StatelessWidget { context.read(), context.read(), ); - if (currentLocalUser - .paperlessUser.canViewDocuments && - currentLocalUser.paperlessUser.canViewTags) { + if (currentLocalUser.paperlessUser.canViewInbox) { inboxCubit.initialize(); } return inboxCubit; diff --git a/lib/features/home/view/route_description.dart b/lib/features/home/view/route_description.dart deleted file mode 100644 index 8440e038..00000000 --- a/lib/features/home/view/route_description.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -class RouteDescription { - final String label; - final Icon icon; - final Icon selectedIcon; - final Widget Function(Widget icon)? badgeBuilder; - final bool enabled; - - RouteDescription({ - required this.label, - required this.icon, - required this.selectedIcon, - this.badgeBuilder, - this.enabled = true, - }); - - NavigationDestination toNavigationDestination() { - return NavigationDestination( - label: label, - icon: badgeBuilder?.call(icon) ?? icon, - selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, - ); - } - - NavigationRailDestination toNavigationRailDestination() { - return NavigationRailDestination( - label: Text(label), - icon: icon, - selectedIcon: selectedIcon, - ); - } - - BottomNavigationBarItem toBottomNavigationBarItem() { - return BottomNavigationBarItem( - label: label, - icon: badgeBuilder?.call(icon) ?? icon, - activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, - ); - } -} diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index 1d8aacd0..b9b2ee4d 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -8,12 +8,6 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/theme.dart'; -const _landingPage = 0; -const _documentsIndex = 1; -const _scannerIndex = 2; -const _labelsIndex = 3; -const _inboxIndex = 4; - class ScaffoldWithNavigationBar extends StatefulWidget { final UserModel authenticatedUser; final StatefulNavigationShell navigationShell; @@ -29,11 +23,6 @@ class ScaffoldWithNavigationBar extends StatefulWidget { } class ScaffoldWithNavigationBarState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -58,7 +47,7 @@ class ScaffoldWithNavigationBarState extends State { Icons.home, color: theme.colorScheme.primary, ), - label: S.of(context)!.home, + label: S.of(context)!.home, ), _toggleDestination( NavigationDestination( diff --git a/lib/features/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart deleted file mode 100644 index f3885cb5..00000000 --- a/lib/features/home/view/widget/verify_identity_page.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; - -import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -import 'package:provider/provider.dart'; - -class VerifyIdentityPage extends StatelessWidget { - const VerifyIdentityPage({super.key}); - - @override - Widget build(BuildContext context) { - return Material( - child: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: Theme.of(context).colorScheme.background, - title: Text(S.of(context)!.verifyYourIdentity), - ), - body: UserAccountBuilder( - builder: (context, settings) { - if (settings == null) { - return const SizedBox.shrink(); - } - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S - .of(context)! - .useTheConfiguredBiometricFactorToAuthenticate) - .paddedSymmetrically(horizontal: 16), - const Icon( - Icons.fingerprint, - size: 96, - ), - Wrap( - alignment: WrapAlignment.spaceBetween, - runAlignment: WrapAlignment.spaceBetween, - runSpacing: 8, - spacing: 8, - children: [ - TextButton( - onPressed: () => _logout(context), - child: Text( - S.of(context)!.disconnect, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ElevatedButton( - onPressed: () => context - .read() - .restoreSessionState(), - child: Text(S.of(context)!.verifyIdentity), - ), - ], - ).padded(16), - ], - ); - }, - ), - ), - ); - } - - void _logout(BuildContext context) { - context.read().logout(); - context.read().clear(); - context.read().clear(); - HydratedBloc.storage.clear(); - } -} diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 5bd59a29..fa4465b4 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -19,8 +19,10 @@ class InboxCubit extends HydratedCubit final LabelRepository _labelRepository; final PaperlessDocumentsApi _documentsApi; + @override final ConnectivityStatusService connectivityStatusService; + @override final DocumentChangedNotifier notifier; @@ -35,21 +37,34 @@ class InboxCubit extends HydratedCubit this._labelRepository, this.notifier, this.connectivityStatusService, - ) : super(InboxState( - labels: _labelRepository.state, - )) { + ) : super(InboxState(labels: _labelRepository.state)) { notifier.addListener( this, onDeleted: remove, onUpdated: (document) { - if (document.tags + final hasInboxTag = document.tags .toSet() .intersection(state.inboxTags.toSet()) - .isEmpty) { + .isNotEmpty; + final wasInInboxBeforeUpdate = + state.documents.map((e) => e.id).contains(document.id); + if (!hasInboxTag && wasInInboxBeforeUpdate) { + print( + "INBOX: Removing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); remove(document); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); - } else { - replace(document); + } else if (hasInboxTag) { + if (wasInInboxBeforeUpdate) { + print( + "INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); + replace(document); + } else { + print( + "INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); + _addDocument(document); + emit( + state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1)); + } } }, ); @@ -61,22 +76,20 @@ class InboxCubit extends HydratedCubit ); } + @override Future initialize() async { await refreshItemsInInboxCount(false); await loadInbox(); } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { + debugPrint("Checking for new items in inbox..."); final stats = await _statsApi.getServerStatistics(); if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { await loadInbox(); } - emit( - state.copyWith( - itemsInInboxCount: stats.documentsInInbox, - ), - ); + emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } /// @@ -85,7 +98,6 @@ class InboxCubit extends HydratedCubit Future loadInbox() async { if (!isClosed) { debugPrint("Initializing inbox..."); - final inboxTags = await _labelRepository.findAllTags().then( (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), ); @@ -113,11 +125,22 @@ class InboxCubit extends HydratedCubit } } + Future _addDocument(DocumentModel document) async { + emit(state.copyWith( + value: [ + ...state.value, + PagedSearchResult( + count: 1, + results: [document], + ), + ], + )); + } + /// /// Fetches inbox tag ids and loads the inbox items (documents). /// Future reloadInbox() async { - emit(state.copyWith(hasLoaded: false, isLoading: true)); final inboxTags = await _labelRepository.findAllTags().then( (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), ); @@ -134,6 +157,7 @@ class InboxCubit extends HydratedCubit } emit(state.copyWith(inboxTags: inboxTags)); updateFilter( + emitLoading: false, filter: DocumentFilter( sortField: SortField.added, tags: TagsQuery.ids(include: inboxTags.toList()), @@ -154,7 +178,7 @@ class InboxCubit extends HydratedCubit document.copyWith(tags: updatedTags), ); // Remove first so document is not replaced first. - remove(document); + // remove(document); notifier.notifyUpdated(updatedDocument); return tagsToRemove; } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 02784f3e..b4efa5e7 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -42,6 +42,7 @@ class _InboxPageState extends State @override void initState() { super.initState(); + context.read().reloadInbox(); WidgetsBinding.instance.addPostFrameCallback((_) { _nestedScrollViewKey.currentState!.innerController .addListener(_scrollExtentChangedListener); diff --git a/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart b/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart deleted file mode 100644 index 328b6c96..00000000 --- a/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; - -class StoragePathWidget extends StatelessWidget { - final StoragePath? storagePath; - final Color? textColor; - final bool isClickable; - final void Function(int? id)? onSelected; - - const StoragePathWidget({ - Key? key, - this.storagePath, - this.textColor, - this.isClickable = true, - this.onSelected, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return AbsorbPointer( - absorbing: !isClickable, - child: GestureDetector( - onTap: () => onSelected?.call(storagePath?.id), - child: Text( - storagePath?.name ?? "-", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor ?? Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } -} diff --git a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart index 37ef0022..4f6c74ae 100644 --- a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart +++ b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -68,9 +69,10 @@ class _FullscreenTagsFormState extends State { @override Widget build(BuildContext context) { + final showFab = MediaQuery.viewInsetsOf(context).bottom == 0; final theme = Theme.of(context); return Scaffold( - floatingActionButton: widget.allowCreation + floatingActionButton: widget.allowCreation && showFab ? FloatingActionButton( heroTag: "fab_tags_form", onPressed: _onAddTag, @@ -238,10 +240,16 @@ class _FullscreenTagsFormState extends State { var matches = _options .where((e) => e.name.trim().toLowerCase().contains(normalizedQuery)); if (matches.isEmpty && widget.allowCreation) { - yield Text(S.of(context)!.noItemsFound); - yield TextButton( - child: Text(S.of(context)!.addTag), - onPressed: _onAddTag, + yield Center( + child: Column( + children: [ + Text(S.of(context)!.noItemsFound).padded(), + TextButton( + child: Text(S.of(context)!.addTag), + onPressed: _onAddTag, + ), + ], + ), ); } for (final tag in matches) { diff --git a/lib/features/labels/view/widgets/fullscreen_label_form.dart b/lib/features/labels/view/widgets/fullscreen_label_form.dart index 8d827b68..c4c649e8 100644 --- a/lib/features/labels/view/widgets/fullscreen_label_form.dart +++ b/lib/features/labels/view/widgets/fullscreen_label_form.dart @@ -69,6 +69,7 @@ class _FullscreenLabelFormState @override Widget build(BuildContext context) { + final showFab = MediaQuery.viewInsetsOf(context).bottom == 0; final theme = Theme.of(context); final options = _filterOptionsByQuery(_textEditingController.text); return Scaffold( @@ -124,6 +125,13 @@ class _FullscreenLabelFormState ), ), ), + floatingActionButton: showFab && widget.onCreateNewLabel != null + ? FloatingActionButton( + heroTag: "fab_label_form", + onPressed: _onCreateNewLabel, + child: const Icon(Icons.add), + ) + : null, body: Builder( builder: (context) { return Column( diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart index e3a047f1..de7e5f51 100644 --- a/lib/features/labels/view/widgets/label_item.dart +++ b/lib/features/labels/view/widgets/label_item.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index d02aee6a..9d6ce8c4 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -76,9 +76,7 @@ class LabelTabView extends StatelessWidget { Text( translateMatchingAlgorithmName( context, l.matchingAlgorithm) + - ((l.match?.isNotEmpty ?? false) - ? ": ${l.match}" - : ""), + (l.match.isNotEmpty ? ": ${l.match}" : ""), maxLines: 2, ), onOpenEditPage: canEdit ? onEdit : null, diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index e4538533..0f5d18f7 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -146,7 +146,7 @@ class _LandingPageState extends State { shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, title: Text(S.of(context)!.documentsInInbox), - onTap: currentUser.canViewTags && currentUser.canViewDocuments + onTap: currentUser.canViewInbox ? () => InboxRoute().go(context) : null, trailing: Text( @@ -161,9 +161,11 @@ class _LandingPageState extends State { shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, title: Text(S.of(context)!.totalDocuments), - onTap: () { - DocumentsRoute().go(context); - }, + onTap: currentUser.canViewDocuments + ? () { + DocumentsRoute().go(context); + } + : null, trailing: Text( stats.documentsTotal.toString(), style: Theme.of(context).textTheme.labelLarge, diff --git a/lib/features/landing/view/widgets/mime_types_pie_chart.dart b/lib/features/landing/view/widgets/mime_types_pie_chart.dart index 6d6593fe..97320ba8 100644 --- a/lib/features/landing/view/widgets/mime_types_pie_chart.dart +++ b/lib/features/landing/view/widgets/mime_types_pie_chart.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index 37ab278d..5a7fdba6 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 9d078b1e..3971acb2 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,11 +1,9 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; @@ -14,6 +12,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart' import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -22,21 +21,26 @@ import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; part 'authentication_state.dart'; +typedef _FutureVoidCallback = Future Function(); + class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessApiFactory _apiFactory; final SessionManager _sessionManager; final ConnectivityStatusService _connectivityService; + final LocalNotificationService _notificationService; AuthenticationCubit( this._localAuthService, this._apiFactory, this._sessionManager, this._connectivityService, + this._notificationService, ) : super(const UnauthenticatedState()); Future login({ @@ -45,7 +49,11 @@ class AuthenticationCubit extends Cubit { ClientCertificate? clientCertificate, }) async { assert(credentials.username != null && credentials.password != null); - emit(const CheckingLoginState()); + if (state is AuthenticatingState) { + // Cancel duplicate login requests + return; + } + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; _debugPrintMessage( "login", @@ -58,35 +66,63 @@ class AuthenticationCubit extends Cubit { credentials, clientCertificate, _sessionManager, + onFetchUserInformation: () async { + emit(const AuthenticatingState( + AuthenticatingStage.fetchingUserInformation)); + }, + onPerformLogin: () async { + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); + }, + onPersistLocalUserData: () async { + emit(const AuthenticatingState( + AuthenticatingStage.persistingLocalUserData)); + }, ); - - // Mark logged in user as currently active user. - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.loggedInUserId = localUserId; - await globalSettings.save(); - - emit(AuthenticatedState(localUserId: localUserId)); - _debugPrintMessage( - "login", - "User successfully logged in.", + } catch (e) { + emit( + AuthenticationErrorState( + serverUrl: serverUrl, + username: credentials.username!, + password: credentials.password!, + clientCertificate: clientCertificate, + ), ); - } catch (error) { - emit(const UnauthenticatedState()); + rethrow; } + + // Mark logged in user as currently active user. + final globalSettings = + Hive.box(HiveBoxes.globalSettings).getValue()!; + globalSettings.loggedInUserId = localUserId; + await globalSettings.save(); + + emit(AuthenticatedState(localUserId: localUserId)); + _debugPrintMessage( + "login", + "User successfully logged in.", + ); } /// Switches to another account if it exists. Future switchAccount(String localUserId) async { emit(const SwitchingAccountsState()); + _debugPrintMessage( + "switchAccount", + "Trying to switch to user $localUserId...", + ); + final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - if (globalSettings.loggedInUserId == localUserId) { - emit(AuthenticatedState(localUserId: localUserId)); - return; - } - final userAccountBox = - Hive.box(HiveBoxes.localUserAccount); + // if (globalSettings.loggedInUserId == localUserId) { + // _debugPrintMessage( + // "switchAccount", + // "User $localUserId is already logged in.", + // ); + // emit(AuthenticatedState(localUserId: localUserId)); + // return; + // } + + final userAccountBox = Hive.localUserAccountBox; if (!userAccountBox.containsKey(localUserId)) { debugPrint("User $localUserId not yet registered."); @@ -99,10 +135,18 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - debugPrint("User not authenticated."); + _debugPrintMessage( + "switchAccount", + "User could not be authenticated.", + ); + emit(VerifyIdentityState(userId: localUserId)); return; } } + final currentlyLoggedInUser = globalSettings.loggedInUserId; + if (currentlyLoggedInUser != localUserId) { + await _notificationService.cancelUserNotifications(localUserId); + } await withEncryptedBox( HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { @@ -131,9 +175,7 @@ class AuthenticationCubit extends Cubit { apiVersion, ); - emit(AuthenticatedState( - localUserId: localUserId, - )); + emit(AuthenticatedState(localUserId: localUserId)); }); } @@ -142,19 +184,33 @@ class AuthenticationCubit extends Cubit { required String serverUrl, ClientCertificate? clientCertificate, required bool enableBiometricAuthentication, + required String locale, }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; - final sessionManager = SessionManager(); - await _addUser( - localUserId, - serverUrl, - credentials, - clientCertificate, - sessionManager, - ); - return localUserId; + final sessionManager = SessionManager([ + LanguageHeaderInterceptor(locale), + ]); + try { + await _addUser( + localUserId, + serverUrl, + credentials, + clientCertificate, + sessionManager, + // onPerformLogin: () async { + // emit(AuthenticatingState(AuthenticatingStage.authenticating)); + // await Future.delayed(const Duration(milliseconds: 500)); + // }, + ); + + return localUserId; + } catch (error, stackTrace) { + print(error); + debugPrintStack(stackTrace: stackTrace); + rethrow; + } } Future removeAccount(String userId) async { @@ -170,28 +226,33 @@ class AuthenticationCubit extends Cubit { } /// - /// Performs a conditional hydration based on the local authentication success. + /// Restores the previous session if exists. /// - Future restoreSessionState() async { + Future restoreSession([String? userId]) async { + emit(const RestoringSessionState()); _debugPrintMessage( "restoreSessionState", "Trying to restore previous session...", ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - final localUserId = globalSettings.loggedInUserId; - if (localUserId == null) { + final restoreSessionForUser = userId ?? globalSettings.loggedInUserId; + // final localUserId = globalSettings.loggedInUserId; + if (restoreSessionForUser == null) { _debugPrintMessage( "restoreSessionState", "There is nothing to restore.", ); + final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty; // If there is nothing to restore, we can quit here. - emit(const UnauthenticatedState()); + emit( + UnauthenticatedState(redirectToAccountSelection: otherAccountsExist), + ); return; } final localUserAccountBox = Hive.box(HiveBoxes.localUserAccount); - final localUserAccount = localUserAccountBox.get(localUserId)!; + final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!; _debugPrintMessage( "restoreSessionState", "Checking if biometric authentication is required...", @@ -207,7 +268,7 @@ class AuthenticationCubit extends Cubit { final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - emit(const RequiresLocalAuthenticationState()); + emit(VerifyIdentityState(userId: restoreSessionForUser)); _debugPrintMessage( "restoreSessionState", "User could not be authenticated.", @@ -231,7 +292,7 @@ class AuthenticationCubit extends Cubit { final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { - return box.get(globalSettings.loggedInUserId!); + return box.get(restoreSessionForUser); }); if (authentication == null) { @@ -290,8 +351,9 @@ class AuthenticationCubit extends Cubit { "Skipping update of server user (server could not be reached).", ); } - - emit(AuthenticatedState(localUserId: localUserId)); + globalSettings.loggedInUserId = restoreSessionForUser; + await globalSettings.save(); + emit(AuthenticatedState(localUserId: restoreSessionForUser)); _debugPrintMessage( "restoreSessionState", @@ -300,7 +362,7 @@ class AuthenticationCubit extends Cubit { } Future logout([bool removeAccount = false]) async { - emit(const LogginOutState()); + emit(const LoggingOutState()); _debugPrintMessage( "logout", "Trying to log out current user...", @@ -308,13 +370,16 @@ class AuthenticationCubit extends Cubit { await _resetExternalState(); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; + await _notificationService.cancelUserNotifications(userId); + + final otherAccountsExist = Hive.localUserAccountBox.length > 1; + emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist)); if (removeAccount) { - this.removeAccount(userId); + await this.removeAccount(userId); } globalSettings.loggedInUserId = null; await globalSettings.save(); - emit(const UnauthenticatedState()); _debugPrintMessage( "logout", "User successfully logged out.", @@ -322,16 +387,8 @@ class AuthenticationCubit extends Cubit { } Future _resetExternalState() async { - _debugPrintMessage( - "_resetExternalState", - "Resetting session manager and clearing storage...", - ); _sessionManager.resetSettings(); await HydratedBloc.storage.clear(); - _debugPrintMessage( - "_resetExternalState", - "Session manager successfully reset and storage cleared.", - ); } Future _addUser( @@ -339,8 +396,11 @@ class AuthenticationCubit extends Cubit { String serverUrl, LoginFormCredentials credentials, ClientCertificate? clientCert, - SessionManager sessionManager, - ) async { + SessionManager sessionManager, { + _FutureVoidCallback? onPerformLogin, + _FutureVoidCallback? onPersistLocalUserData, + _FutureVoidCallback? onFetchUserInformation, + }) async { assert(credentials.username != null && credentials.password != null); _debugPrintMessage("_addUser", "Adding new user $localUserId..."); @@ -356,6 +416,8 @@ class AuthenticationCubit extends Cubit { "Trying to login user ${credentials.username} on $serverUrl...", ); + await onPerformLogin?.call(); + final token = await authApi.login( username: credentials.username!, password: credentials.password!, @@ -384,6 +446,7 @@ class AuthenticationCubit extends Cubit { ); throw InfoMessageException(code: ErrorCode.userAlreadyExists); } + await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); _debugPrintMessage( "_addUser", @@ -413,6 +476,7 @@ class AuthenticationCubit extends Cubit { "_addUser", "Persisting local user account...", ); + await onPersistLocalUserData?.call(); // Create user account await userAccountBox.put( localUserId, @@ -490,7 +554,7 @@ class AuthenticationCubit extends Cubit { "API version ($apiVersion) successfully retrieved.", ); return apiVersion; - } on DioException catch (e) { + } on DioException catch (_) { return defaultValue; } } diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index bc4d29c8..1ad5fabf 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -7,34 +7,76 @@ sealed class AuthenticationState { switch (this) { AuthenticatedState() => true, _ => false }; } -class UnauthenticatedState extends AuthenticationState { - const UnauthenticatedState(); +class UnauthenticatedState extends AuthenticationState with EquatableMixin { + final bool redirectToAccountSelection; + + const UnauthenticatedState({this.redirectToAccountSelection = false}); + + @override + List get props => [redirectToAccountSelection]; +} + +class RestoringSessionState extends AuthenticationState { + const RestoringSessionState(); } -class RequiresLocalAuthenticationState extends AuthenticationState { - const RequiresLocalAuthenticationState(); +class VerifyIdentityState extends AuthenticationState { + final String userId; + const VerifyIdentityState({required this.userId}); } -class CheckingLoginState extends AuthenticationState { - const CheckingLoginState(); +class AuthenticatingState extends AuthenticationState with EquatableMixin { + final AuthenticatingStage currentStage; + const AuthenticatingState(this.currentStage); + + @override + List get props => [currentStage]; } -class LogginOutState extends AuthenticationState { - const LogginOutState(); +class LoggingOutState extends AuthenticationState { + const LoggingOutState(); } -class AuthenticatedState extends AuthenticationState { +class AuthenticatedState extends AuthenticationState with EquatableMixin { final String localUserId; - const AuthenticatedState({ - required this.localUserId, - }); + const AuthenticatedState({required this.localUserId}); + + @override + List get props => [localUserId]; } class SwitchingAccountsState extends AuthenticationState { const SwitchingAccountsState(); } -class AuthenticationErrorState extends AuthenticationState { - const AuthenticationErrorState(); +class AuthenticationErrorState extends AuthenticationState with EquatableMixin { + final ErrorCode? errorCode; + final String serverUrl; + final ClientCertificate? clientCertificate; + final String username; + final String password; + + const AuthenticationErrorState({ + this.errorCode, + required this.serverUrl, + this.clientCertificate, + required this.username, + required this.password, + }); + + @override + List get props => [ + errorCode, + serverUrl, + clientCertificate, + username, + password, + ]; +} + +enum AuthenticatingStage { + authenticating, + persistingLocalUserData, + fetchingUserInformation, } diff --git a/lib/features/login/cubit/old_authentication_state.dart b/lib/features/login/cubit/old_authentication_state.dart deleted file mode 100644 index a2bd8062..00000000 --- a/lib/features/login/cubit/old_authentication_state.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class OldAuthenticationState with EquatableMixin { - final bool showBiometricAuthenticationScreen; - final bool isAuthenticated; - final String? username; - final String? fullName; - final String? localUserId; - final int? apiVersion; - - const OldAuthenticationState({ - this.isAuthenticated = false, - this.showBiometricAuthenticationScreen = false, - this.username, - this.fullName, - this.localUserId, - this.apiVersion, - }); - - OldAuthenticationState copyWith({ - bool? isAuthenticated, - bool? showBiometricAuthenticationScreen, - String? username, - String? fullName, - String? localUserId, - int? apiVersion, - }) { - return OldAuthenticationState( - isAuthenticated: isAuthenticated ?? this.isAuthenticated, - showBiometricAuthenticationScreen: showBiometricAuthenticationScreen ?? - this.showBiometricAuthenticationScreen, - username: username ?? this.username, - fullName: fullName ?? this.fullName, - localUserId: localUserId ?? this.localUserId, - apiVersion: apiVersion ?? this.apiVersion, - ); - } - - @override - List get props => [ - localUserId, - username, - fullName, - isAuthenticated, - showBiometricAuthenticationScreen, - apiVersion, - ]; -} diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart index 8c920ba7..00c24c8e 100644 --- a/lib/features/login/model/client_certificate.dart +++ b/lib/features/login/model/client_certificate.dart @@ -12,5 +12,9 @@ class ClientCertificate { @HiveField(1) String? passphrase; - ClientCertificate({required this.bytes, this.passphrase}); + + ClientCertificate({ + required this.bytes, + this.passphrase, + }); } diff --git a/lib/features/login/model/client_certificate_form_model.dart b/lib/features/login/model/client_certificate_form_model.dart index afb9ddb9..b168d151 100644 --- a/lib/features/login/model/client_certificate_form_model.dart +++ b/lib/features/login/model/client_certificate_form_model.dart @@ -7,9 +7,16 @@ class ClientCertificateFormModel { final Uint8List bytes; final String? passphrase; - ClientCertificateFormModel({required this.bytes, this.passphrase}); + ClientCertificateFormModel({ + required this.bytes, + this.passphrase, + }); - ClientCertificateFormModel copyWith({Uint8List? bytes, String? passphrase}) { + ClientCertificateFormModel copyWith({ + Uint8List? bytes, + String? passphrase, + String? filePath, + }) { return ClientCertificateFormModel( bytes: bytes ?? this.bytes, passphrase: passphrase ?? this.passphrase, diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index 5c48e445..e7ab5b67 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -3,27 +3,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/model/info_message_exception.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/login_pages/server_connection_page.dart'; -import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; -import 'widgets/login_pages/server_login_page.dart'; -import 'widgets/never_scrollable_scroll_behavior.dart'; - class AddAccountPage extends StatefulWidget { final FutureOr Function( BuildContext context, @@ -33,17 +27,27 @@ class AddAccountPage extends StatefulWidget { ClientCertificate? clientCertificate, ) onSubmit; - final String submitText; - final String titleString; + final String? initialServerUrl; + final String? initialUsername; + final String? initialPassword; + final ClientCertificate? initialClientCertificate; + final String submitText; + final String titleText; final bool showLocalAccounts; + final Widget? bottomLeftButton; const AddAccountPage({ Key? key, required this.onSubmit, required this.submitText, - required this.titleString, + required this.titleText, this.showLocalAccounts = false, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + this.bottomLeftButton, }) : super(key: key); @override @@ -52,86 +56,170 @@ class AddAccountPage extends StatefulWidget { class _AddAccountPageState extends State { final _formKey = GlobalKey(); + bool _isCheckingConnection = false; + ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; - final PageController _pageController = PageController(); - + bool _isFormSubmitted = false; @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.localUserAccount).listenable(), - builder: (context, localAccounts, child) { - return Scaffold( - resizeToAvoidBottomInset: false, - body: FormBuilder( - key: _formKey, - child: PageView( - controller: _pageController, - scrollBehavior: NeverScrollableScrollBehavior(), - children: [ - if (widget.showLocalAccounts && localAccounts.isNotEmpty) - Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.logInToExistingAccount), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - child: Text(S.of(context)!.goToLogin), - onPressed: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ), - ), - body: ListView.builder( - itemBuilder: (context, index) { - final account = localAccounts.values.elementAt(index); - return Card( - child: UserAccountListTile( - account: account, - onTap: () { - context - .read() - .switchAccount(account.id); - }, - ), - ); - }, - itemCount: localAccounts.length, - ), - ), - ServerConnectionPage( - titleText: widget.titleString, - formBuilderKey: _formKey, - onContinue: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ServerLoginPage( - formBuilderKey: _formKey, - submitText: widget.submitText, - onSubmit: _login, - ), - ], + return Scaffold( + appBar: AppBar( + title: Text(widget.titleText), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: widget.bottomLeftButton != null + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.end, + children: [ + if (widget.bottomLeftButton != null) widget.bottomLeftButton!, + FilledButton( + child: Text(S.of(context)!.loginPageSignInTitle), + onPressed: _reachabilityStatus == ReachabilityStatus.reachable && + !_isFormSubmitted + ? _onSubmit + : null, ), - ), - ); - }, + ], + ), + ), + resizeToAvoidBottomInset: true, + body: FormBuilder( + key: _formKey, + child: ListView( + children: [ + ServerAddressFormField( + initialValue: widget.initialServerUrl, + onSubmit: (address) { + _updateReachability(address); + }, + ).padded(), + ClientCertificateFormField( + initialBytes: widget.initialClientCertificate?.bytes, + initialPassphrase: widget.initialClientCertificate?.passphrase, + onChanged: (_) => _updateReachability(), + ).padded(), + _buildStatusIndicator(), + if (_reachabilityStatus == ReachabilityStatus.reachable) ...[ + UserCredentialsFormField( + formKey: _formKey, + initialUsername: widget.initialUsername, + initialPassword: widget.initialPassword, + onFieldsSubmitted: _onSubmit, + ), + Text( + S.of(context)!.loginRequiredPermissionsHint, + style: Theme.of(context).textTheme.bodySmall?.apply( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + ), + ).padded(16), + ] + ], + ), + ), + ); + } + + Future _updateReachability([String? address]) async { + setState(() { + _isCheckingConnection = true; + }); + final certForm = + _formKey.currentState?.getRawValue( + ClientCertificateFormField.fkClientCertificate, ); + final status = await context + .read() + .isPaperlessServerReachable( + address ?? + _formKey.currentState! + .getRawValue(ServerAddressFormField.fkServerAddress), + certForm != null + ? ClientCertificate( + bytes: certForm.bytes, + passphrase: certForm.passphrase, + ) + : null, + ); + setState(() { + _isCheckingConnection = false; + _reachabilityStatus = status; + }); + } + + Widget _buildStatusIndicator() { + if (_isCheckingConnection) { + return const ListTile(); + } + + Widget _buildIconText( + IconData icon, + String text, [ + Color? color, + ]) { + return ListTile( + title: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), + ), + leading: Icon( + icon, + color: color, + ), + ); + } + + Color errorColor = Theme.of(context).colorScheme.error; + switch (_reachabilityStatus) { + case ReachabilityStatus.unknown: + return Container(); + case ReachabilityStatus.reachable: + return _buildIconText( + Icons.done, + S.of(context)!.connectionSuccessfulylEstablished, + Colors.green, + ); + case ReachabilityStatus.notReachable: + return _buildIconText( + Icons.close, + S.of(context)!.couldNotEstablishConnectionToTheServer, + errorColor, + ); + case ReachabilityStatus.unknownHost: + return _buildIconText( + Icons.close, + S.of(context)!.hostCouldNotBeResolved, + errorColor, + ); + case ReachabilityStatus.missingClientCertificate: + return _buildIconText( + Icons.close, + S.of(context)!.loginPageReachabilityMissingClientCertificateText, + errorColor, + ); + case ReachabilityStatus.invalidClientCertificateConfiguration: + return _buildIconText( + Icons.close, + S.of(context)!.incorrectOrMissingCertificatePassphrase, + errorColor, + ); + case ReachabilityStatus.connectionTimeout: + return _buildIconText( + Icons.close, + S.of(context)!.connectionTimedOut, + errorColor, + ); + } } - Future _login() async { + Future _onSubmit() async { FocusScope.of(context).unfocus(); + setState(() { + _isFormSubmitted = true; + }); if (_formKey.currentState?.saveAndValidate() ?? false) { final form = _formKey.currentState!.value; ClientCertificate? clientCert; @@ -162,6 +250,10 @@ class _AddAccountPageState extends State { showInfoMessage(context, error); } catch (error) { showGenericError(context, error); + } finally { + setState(() { + _isFormSubmitted = false; + }); } } } diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 44d2e367..ffc2998b 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; @@ -13,18 +13,41 @@ import 'package:paperless_mobile/features/login/model/login_form_credentials.dar import 'package:paperless_mobile/features/login/view/add_account_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; -import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; class LoginPage extends StatelessWidget { - const LoginPage({super.key}); + final String? initialServerUrl; + final String? initialUsername; + final String? initialPassword; + final ClientCertificate? initialClientCertificate; + + const LoginPage({ + super.key, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + }); @override Widget build(BuildContext context) { return AddAccountPage( - titleString: S.of(context)!.connectToPaperless, + titleText: S.of(context)!.connectToPaperless, submitText: S.of(context)!.signIn, onSubmit: _onLogin, showLocalAccounts: true, + initialServerUrl: initialServerUrl, + initialUsername: initialUsername, + initialPassword: initialPassword, + initialClientCertificate: initialClientCertificate, + bottomLeftButton: Hive.localUserAccountBox.isNotEmpty + ? TextButton( + child: Text(S.of(context)!.logInToExistingAccount), + onPressed: () { + const LoginToExistingAccountRoute().go(context); + }, + ) + : null, ); } diff --git a/lib/features/login/view/login_to_existing_account_page.dart b/lib/features/login/view/login_to_existing_account_page.dart new file mode 100644 index 00000000..be2a11a9 --- /dev/null +++ b/lib/features/login/view/login_to_existing_account_page.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; + +class LoginToExistingAccountPage extends StatelessWidget { + const LoginToExistingAccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: Hive.localUserAccountBox.listenable(), + builder: (context, value, _) { + final localAccounts = value.values; + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(S.of(context)!.logInToExistingAccount), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + child: Text(S.of(context)!.addAnotherAccount), + onPressed: () { + const LoginRoute().go(context); + }, + ), + ], + ), + ), + body: ListView.builder( + itemBuilder: (context, index) { + final account = localAccounts.elementAt(index); + return Card( + child: UserAccountListTile( + account: account, + onTap: () { + context + .read() + .switchAccount(account.id); + }, + trailing: IconButton( + tooltip: S.of(context)!.remove, + icon: Icon(Icons.close), + onPressed: () { + context + .read() + .removeAccount(account.id); + }, + ), + ), + ); + }, + itemCount: localAccounts.length, + ), + ); + }, + ); + } +} diff --git a/lib/features/login/view/verify_identity_page.dart b/lib/features/login/view/verify_identity_page.dart new file mode 100644 index 00000000..cd482dae --- /dev/null +++ b/lib/features/login/view/verify_identity_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; +import 'package:provider/provider.dart'; + +class VerifyIdentityPage extends StatelessWidget { + final String userId; + const VerifyIdentityPage({super.key, required this.userId}); + + @override + Widget build(BuildContext context) { + return Material( + child: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Theme.of(context).colorScheme.background, + title: Text(S.of(context)!.verifyYourIdentity), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + const LoginToExistingAccountRoute().go(context); + }, + child: Text(S.of(context)!.goToLogin), + ), + FilledButton( + onPressed: () => + context.read().restoreSession(userId), + child: Text(S.of(context)!.verifyIdentity), + ), + ], + ), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate, + textAlign: TextAlign.center, + ).paddedSymmetrically(horizontal: 16), + const Icon( + Icons.fingerprint, + size: 96, + ), + // Wrap( + // alignment: WrapAlignment.spaceBetween, + // runAlignment: WrapAlignment.spaceBetween, + // runSpacing: 8, + // spacing: 8, + // children: [ + + // ], + // ).padded(16), + ], + ), + ), + ); + } +} diff --git a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart index 3936de8e..b0c37537 100644 --- a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -12,11 +13,16 @@ import 'obscured_input_text_form_field.dart'; class ClientCertificateFormField extends StatefulWidget { static const fkClientCertificate = 'clientCertificate'; + final String? initialPassphrase; + final Uint8List? initialBytes; + final void Function(ClientCertificateFormModel? cert) onChanged; const ClientCertificateFormField({ - Key? key, + super.key, required this.onChanged, - }) : super(key: key); + this.initialPassphrase, + this.initialBytes, + }); @override State createState() => @@ -31,7 +37,12 @@ class _ClientCertificateFormFieldState return FormBuilderField( key: const ValueKey('login-client-cert'), onChanged: widget.onChanged, - initialValue: null, + initialValue: widget.initialBytes != null + ? ClientCertificateFormModel( + bytes: widget.initialBytes!, + passphrase: widget.initialPassphrase, + ) + : null, validator: (value) { if (value == null) { return null; @@ -108,8 +119,7 @@ class _ClientCertificateFormFieldState ), label: S.of(context)!.passphrase, ).padded(), - ] else - ...[] + ] ], ), ), @@ -122,20 +132,23 @@ class _ClientCertificateFormFieldState } Future _onSelectFile( - FormFieldState field) async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FormFieldState field, + ) async { + final result = await FilePicker.platform.pickFiles( allowMultiple: false, ); - if (result != null && result.files.single.path != null) { - File file = File(result.files.single.path!); - setState(() { - _selectedFile = file; - }); - final changedValue = - field.value?.copyWith(bytes: file.readAsBytesSync()) ?? - ClientCertificateFormModel(bytes: file.readAsBytesSync()); - field.didChange(changedValue); + if (result == null || result.files.single.path == null) { + return; } + File file = File(result.files.single.path!); + setState(() { + _selectedFile = file; + }); + final bytes = await file.readAsBytes(); + + final changedValue = field.value?.copyWith(bytes: bytes) ?? + ClientCertificateFormModel(bytes: bytes); + field.didChange(changedValue); } Widget _buildSelectedFileText( diff --git a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart index fe090063..cff93d86 100644 --- a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart @@ -8,11 +8,12 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class ServerAddressFormField extends StatefulWidget { static const String fkServerAddress = "serverAddress"; - + final String? initialValue; final void Function(String? address) onSubmit; const ServerAddressFormField({ Key? key, required this.onSubmit, + this.initialValue, }) : super(key: key); @override @@ -38,6 +39,7 @@ class _ServerAddressFormFieldState extends State { @override Widget build(BuildContext context) { return FormBuilderField( + initialValue: widget.initialValue, name: ServerAddressFormField.fkServerAddress, autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { @@ -90,7 +92,7 @@ class _ServerAddressFormFieldState extends State { ) : null, ), - autofocus: true, + autofocus: false, onSubmitted: (_) { onFieldSubmitted(); _formatInput(); diff --git a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart index deb24c1e..397d5637 100644 --- a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart @@ -1,19 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/obscured_input_text_form_field.dart'; +import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class UserCredentialsFormField extends StatefulWidget { static const fkCredentials = 'credentials'; final void Function() onFieldsSubmitted; - + final String? initialUsername; + final String? initialPassword; + final GlobalKey formKey; const UserCredentialsFormField({ Key? key, required this.onFieldsSubmitted, + this.initialUsername, + this.initialPassword, + required this.formKey, }) : super(key: key); @override @@ -28,6 +36,10 @@ class _UserCredentialsFormFieldState extends State { @override Widget build(BuildContext context) { return FormBuilderField( + initialValue: LoginFormCredentials( + password: widget.initialPassword, + username: widget.initialUsername, + ), name: UserCredentialsFormField.fkCredentials, builder: (field) => AutofillGroup( child: Column( @@ -50,6 +62,17 @@ class _UserCredentialsFormFieldState extends State { if (value?.trim().isEmpty ?? true) { return S.of(context)!.usernameMustNotBeEmpty; } + final serverAddress = widget.formKey.currentState! + .getRawValue( + ServerAddressFormField.fkServerAddress); + if (serverAddress != null) { + final userExists = Hive.localUserAccountBox.values + .map((e) => e.id) + .contains('$value@$serverAddress'); + if (userExists) { + return S.of(context)!.userAlreadyExists; + } + } return null; }, autofillHints: const [AutofillHints.username], diff --git a/lib/features/login/view/widgets/login_pages/server_connection_page.dart b/lib/features/login/view/widgets/login_pages/server_connection_page.dart deleted file mode 100644 index 29a3c960..00000000 --- a/lib/features/login/view/widgets/login_pages/server_connection_page.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; -import 'package:paperless_mobile/features/login/model/reachability_status.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -import 'package:provider/provider.dart'; - -class ServerConnectionPage extends StatefulWidget { - final GlobalKey formBuilderKey; - final VoidCallback onContinue; - final String titleText; - - const ServerConnectionPage({ - super.key, - required this.formBuilderKey, - required this.onContinue, - required this.titleText, - }); - - @override - State createState() => _ServerConnectionPageState(); -} - -class _ServerConnectionPageState extends State { - bool _isCheckingConnection = false; - ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - toolbarHeight: kToolbarHeight - 4, - title: Text(widget.titleText), - bottom: PreferredSize( - child: _isCheckingConnection - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), - preferredSize: const Size.fromHeight(4.0), - ), - ), - resizeToAvoidBottomInset: true, - body: SingleChildScrollView( - child: Column( - children: [ - ServerAddressFormField( - onSubmit: (address) { - _updateReachability(address); - }, - ).padded(), - ClientCertificateFormField( - onChanged: (_) => _updateReachability(), - ).padded(), - _buildStatusIndicator(), - ], - ).padded(), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text(S.of(context)!.testConnection), - onPressed: _updateReachability, - ), - FilledButton( - child: Text(S.of(context)!.continueLabel), - onPressed: _reachabilityStatus == ReachabilityStatus.reachable - ? widget.onContinue - : null, - ), - ], - ), - ), - ); - } - - Future _updateReachability([String? address]) async { - setState(() { - _isCheckingConnection = true; - }); - final certForm = widget.formBuilderKey.currentState - ?.getRawValue(ClientCertificateFormField.fkClientCertificate) - as ClientCertificateFormModel?; - final status = await context - .read() - .isPaperlessServerReachable( - address ?? - widget.formBuilderKey.currentState! - .getRawValue(ServerAddressFormField.fkServerAddress), - certForm != null - ? ClientCertificate( - bytes: certForm.bytes, passphrase: certForm.passphrase) - : null, - ); - setState(() { - _isCheckingConnection = false; - _reachabilityStatus = status; - }); - } - - Widget _buildStatusIndicator() { - if (_isCheckingConnection) { - return const ListTile(); - } - Color errorColor = Theme.of(context).colorScheme.error; - switch (_reachabilityStatus) { - case ReachabilityStatus.unknown: - return Container(); - case ReachabilityStatus.reachable: - return _buildIconText( - Icons.done, - S.of(context)!.connectionSuccessfulylEstablished, - Colors.green, - ); - case ReachabilityStatus.notReachable: - return _buildIconText( - Icons.close, - S.of(context)!.couldNotEstablishConnectionToTheServer, - errorColor, - ); - case ReachabilityStatus.unknownHost: - return _buildIconText( - Icons.close, - S.of(context)!.hostCouldNotBeResolved, - errorColor, - ); - case ReachabilityStatus.missingClientCertificate: - return _buildIconText( - Icons.close, - S.of(context)!.loginPageReachabilityMissingClientCertificateText, - errorColor, - ); - case ReachabilityStatus.invalidClientCertificateConfiguration: - return _buildIconText( - Icons.close, - S.of(context)!.incorrectOrMissingCertificatePassphrase, - errorColor, - ); - case ReachabilityStatus.connectionTimeout: - return _buildIconText( - Icons.close, - S.of(context)!.connectionTimedOut, - errorColor, - ); - } - } - - Widget _buildIconText( - IconData icon, - String text, [ - Color? color, - ]) { - return ListTile( - title: Text( - text, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), - ), - leading: Icon( - icon, - color: color, - ), - ); - } -} diff --git a/lib/features/login/view/widgets/login_pages/server_login_page.dart b/lib/features/login/view/widgets/login_pages/server_login_page.dart deleted file mode 100644 index 1951aa3b..00000000 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -class ServerLoginPage extends StatefulWidget { - final String submitText; - final Future Function() onSubmit; - final GlobalKey formBuilderKey; - - const ServerLoginPage({ - super.key, - required this.onSubmit, - required this.formBuilderKey, - required this.submitText, - }); - - @override - State createState() => _ServerLoginPageState(); -} - -class _ServerLoginPageState extends State { - bool _isLoginLoading = false; - @override - Widget build(BuildContext context) { - final serverAddress = (widget.formBuilderKey.currentState - ?.getRawValue(ServerAddressFormField.fkServerAddress) - as String?) - ?.replaceAll(RegExp(r'https?://'), '') ?? - ''; - return Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.loginPageSignInTitle), - bottom: _isLoginLoading - ? const PreferredSize( - preferredSize: Size.fromHeight(4.0), - child: LinearProgressIndicator(), - ) - : null, - ), - body: ListView( - children: [ - Text( - S.of(context)!.signInToServer(serverAddress) + ":", - style: Theme.of(context).textTheme.labelLarge, - ).padded(16), - UserCredentialsFormField( - onFieldsSubmitted: widget.onSubmit, - ), - Text( - S.of(context)!.loginRequiredPermissionsHint, - style: Theme.of(context).textTheme.bodySmall?.apply( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6), - ), - ).padded(16), - ], - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - onPressed: !_isLoginLoading - ? () async { - setState(() => _isLoginLoading = true); - try { - await widget.onSubmit(); - } finally { - setState(() => _isLoginLoading = false); - } - } - : null, - child: Text(S.of(context)!.signIn), - ) - ], - ), - ), - ); - } -} diff --git a/lib/features/login/view/widgets/login_transition_page.dart b/lib/features/login/view/widgets/login_transition_page.dart new file mode 100644 index 00000000..3976418d --- /dev/null +++ b/lib/features/login/view/widgets/login_transition_page.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/theme.dart'; + +class LoginTransitionPage extends StatelessWidget { + final String text; + const LoginTransitionPage({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: AnnotatedRegion( + value: buildOverlayStyle( + Theme.of(context), + systemNavigationBarColor: Theme.of(context).colorScheme.background, + ), + child: Scaffold( + body: Stack( + alignment: Alignment.center, + children: [ + const CircularProgressIndicator(), + Align( + alignment: Alignment.bottomCenter, + child: Text(text).paddedOnly(bottom: 24), + ), + ], + ).padded(16), + ), + ), + ); + } +} diff --git a/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart b/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart deleted file mode 100644 index bfdc0fda..00000000 --- a/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class NeverScrollableScrollBehavior extends ScrollBehavior { - @override - ScrollPhysics getScrollPhysics(BuildContext context) { - return const NeverScrollableScrollPhysics(); - } -} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index 01c8246d..bfad7fc4 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -54,6 +54,7 @@ class LocalNotificationService { required bool finished, required String locale, required String userId, + double? progress, }) async { final tr = await S.delegate.load(Locale(locale)); @@ -68,8 +69,10 @@ class LocalNotificationService { android: AndroidNotificationDetails( NotificationChannel.documentDownload.id + "_${document.id}", NotificationChannel.documentDownload.name, + progress: ((progress ?? 0) * 100).toInt(), + maxProgress: 100, + indeterminate: progress == null && !finished, ongoing: !finished, - indeterminate: true, importance: Importance.max, priority: Priority.high, showProgress: !finished, diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 1c683136..3c9cd670 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:rxdart/streams.dart'; import 'paged_documents_state.dart'; @@ -51,6 +50,7 @@ mixin DocumentPagingBlocMixin /// Use [loadMore] to load more data. Future updateFilter({ final DocumentFilter filter = const DocumentFilter(), + bool emitLoading = true, }) async { final hasConnection = await connectivityStatusService.isConnectedToInternet(); @@ -60,7 +60,9 @@ mixin DocumentPagingBlocMixin .expand((page) => page.results) .where((doc) => filter.matches(doc)) .toList(); - emit(state.copyWithPaged(isLoading: true)); + if (emitLoading) { + emit(state.copyWithPaged(isLoading: true)); + } emit( state.copyWithPaged( @@ -79,7 +81,9 @@ mixin DocumentPagingBlocMixin return; } try { - emit(state.copyWithPaged(isLoading: true)); + if (emitLoading) { + emit(state.copyWithPaged(isLoading: true)); + } final result = await api.findAll(filter.copyWith(page: 1)); emit( @@ -146,7 +150,7 @@ mixin DocumentPagingBlocMixin /// Deletes a document and removes it from the currently loaded state. /// Future delete(DocumentModel document) async { - emit(state.copyWithPaged(isLoading: true)); + // emit(state.copyWithPaged(isLoading: true)); try { await api.delete(document); notifier.notifyDeleted(document); @@ -213,6 +217,7 @@ mixin DocumentPagingBlocMixin } } + @override Future close() { notifier.removeListener(this); diff --git a/lib/features/saved_view/view/saved_view_list.dart b/lib/features/saved_view/view/saved_view_list.dart deleted file mode 100644 index bc6b9357..00000000 --- a/lib/features/saved_view/view/saved_view_list.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_loading_sliver_list.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -class SavedViewList extends StatelessWidget { - const SavedViewList({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivity) { - return BlocBuilder( - builder: (context, state) { - return state.when( - initial: () => const SavedViewLoadingSliverList(), - loading: () => const SavedViewLoadingSliverList(), - loaded: (savedViews) { - if (savedViews.isEmpty) { - return SliverToBoxAdapter( - child: HintCard( - hintText: S - .of(context)! - .createViewsToQuicklyFilterYourDocuments, - ), - ); - } - return SliverList.builder( - itemBuilder: (context, index) { - final view = savedViews.values.elementAt(index); - return ListTile( - enabled: connectivity.isConnected, - title: Text(view.name), - subtitle: Text( - S.of(context)!.nFiltersSet(view.filterRules.length), - ), - onTap: () { - pushSavedViewDetailsRoute(context, savedView: view); - }, - ); - }, - itemCount: savedViews.length, - ); - }, - error: () => const SliverToBoxAdapter( - child: Center( - child: Text( - "An error occurred while trying to load the saved views.", - ), - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/features/saved_view/view/saved_view_loading_sliver_list.dart b/lib/features/saved_view/view/saved_view_loading_sliver_list.dart deleted file mode 100644 index 24681156..00000000 --- a/lib/features/saved_view/view/saved_view_loading_sliver_list.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; - -class SavedViewLoadingSliverList extends StatelessWidget { - const SavedViewLoadingSliverList({super.key}); - - @override - Widget build(BuildContext context) { - return SliverList.builder( - itemBuilder: (context, index) => ShimmerPlaceholder( - child: ListTile( - title: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 300, - height: 14, - color: Colors.white, - ), - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 150, - height: 12, - color: Colors.white, - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/saved_view_details/view/saved_view_details_page.dart b/lib/features/saved_view_details/view/saved_view_details_page.dart deleted file mode 100644 index a98634b5..00000000 --- a/lib/features/saved_view_details/view/saved_view_details_page.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; -import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; -import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; - -class SavedViewDetailsPage extends StatefulWidget { - final Future Function(SavedView savedView) onDelete; - const SavedViewDetailsPage({ - super.key, - required this.onDelete, - }); - - @override - State createState() => _SavedViewDetailsPageState(); -} - -class _SavedViewDetailsPageState extends State - with DocumentPagingViewMixin { - @override - final pagingScrollController = ScrollController(); - - @override - Widget build(BuildContext context) { - final cubit = context.watch(); - return Scaffold( - appBar: AppBar( - title: Text(cubit.savedView.name), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - final shouldDelete = await showDialog( - context: context, - builder: (context) => ConfirmDeleteSavedViewDialog( - view: cubit.savedView, - ), - ) ?? - false; - if (shouldDelete) { - await widget.onDelete(cubit.savedView); - context.pop(context); - } - }, - ), - BlocBuilder( - builder: (context, state) { - return ViewTypeSelectionWidget( - viewType: state.viewType, - onChanged: cubit.setViewType, - ); - }, - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - if (state.hasLoaded && state.documents.isEmpty) { - return DocumentsEmptyState(state: state); - } - return BlocBuilder( - builder: (context, connectivity) { - return CustomScrollView( - controller: pagingScrollController, - slivers: [ - SliverAdaptiveDocumentsView( - documents: state.documents, - hasInternetConnection: connectivity.isConnected, - isLabelClickable: false, - isLoading: state.isLoading, - hasLoaded: state.hasLoaded, - onTap: (document) { - DocumentDetailsRoute( - $extra: document, - isLabelClickable: false, - ).push(context); - }, - viewType: state.viewType, - ), - if (state.hasLoaded && state.isLoading) - const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ) - ], - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index ad1ff930..2ebb4598 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index 5cce0354..1669716e 100644 --- a/lib/features/settings/view/manage_accounts_page.dart +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -1,16 +1,13 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; -import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; -import 'package:paperless_mobile/features/login/view/add_account_page.dart'; import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; import 'package:provider/provider.dart'; class ManageAccountsPage extends StatelessWidget { @@ -20,16 +17,15 @@ class ManageAccountsPage extends StatelessWidget { Widget build(BuildContext context) { return GlobalSettingsBuilder( builder: (context, globalSettings) { - // This is one of the few places where the currentLoggedInUser can be null - // (exactly after loggin out as the current user to be precise). - if (globalSettings.loggedInUserId == null) { - return const SizedBox.shrink(); - } + // // This is one of the few places where the currentLoggedInUser can be null + // // (exactly after loggin out as the current user to be precise). + return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.localUserAccount) - .listenable(), + valueListenable: Hive.localUserAccountBox.listenable(), builder: (context, box, _) { + if (globalSettings.loggedInUserId == null) { + return const SizedBox.shrink(); + } final userIds = box.keys.toList().cast(); final otherAccounts = userIds .whereNot((element) => element == globalSettings.loggedInUserId) @@ -70,6 +66,7 @@ class ManageAccountsPage extends StatelessWidget { ], onSelected: (value) async { if (value == 0) { + Navigator.of(context).pop(); await context .read() .logout(true); @@ -133,11 +130,12 @@ class ManageAccountsPage extends StatelessWidget { _onAddAccount(context, globalSettings.loggedInUserId!); }, ), - if (context.watch().hasMultiUserSupport) - ListTile( - leading: const Icon(Icons.admin_panel_settings), - title: Text(S.of(context)!.managePermissions), - ), + //TODO: Implement permission/user settings at some point... + // if (context.watch().hasMultiUserSupport) + // ListTile( + // leading: const Icon(Icons.admin_panel_settings), + // title: Text(S.of(context)!.managePermissions), + // ), ], ); }, @@ -147,43 +145,43 @@ class ManageAccountsPage extends StatelessWidget { } Future _onAddAccount(BuildContext context, String currentUser) async { - final userId = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AddAccountPage( - titleString: S.of(context)!.addAccount, - onSubmit: (context, username, password, serverUrl, - clientCertificate) async { - final userId = await context.read().addAccount( - credentials: LoginFormCredentials( - username: username, - password: password, - ), - clientCertificate: clientCertificate, - serverUrl: serverUrl, - //TODO: Ask user whether to enable biometric authentication - enableBiometricAuthentication: false, - ); - Navigator.of(context).pop(userId); - }, - submitText: S.of(context)!.addAccount, - ), - ), - ); - if (userId != null) { - final shoudSwitch = await showDialog( - context: context, - builder: (context) => const SwitchAccountDialog(), - ) ?? - false; - if (shoudSwitch) { - _onSwitchAccount(context, currentUser, userId); - } - } + Navigator.of(context).pop(); + AddAccountRoute().push(context); + // final userId = await Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => AddAccountPage( + // titleText: S.of(context)!.addAccount, + // onSubmit: (context, username, password, serverUrl, + // clientCertificate) async { + // try { + // final userId = + // await context.read().addAccount( + // credentials: LoginFormCredentials( + // username: username, + // password: password, + // ), + // clientCertificate: clientCertificate, + // serverUrl: serverUrl, + // //TODO: Ask user whether to enable biometric authentication + // enableBiometricAuthentication: false, + // ); + + // Navigator.of(context).pop(userId); + // } on PaperlessFormValidationException catch (error) {} + // }, + // submitText: S.of(context)!.addAccount, + // ), + // ), + // ); + } void _onSwitchAccount( - BuildContext context, String currentUser, String newUser) async { + BuildContext context, + String currentUser, + String newUser, + ) async { if (currentUser == newUser) return; Navigator.of(context).pop(); diff --git a/lib/features/settings/view/pages/switching_accounts_page.dart b/lib/features/settings/view/pages/switching_accounts_page.dart deleted file mode 100644 index 8bfd21db..00000000 --- a/lib/features/settings/view/pages/switching_accounts_page.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -class SwitchingAccountsPage extends StatelessWidget { - const SwitchingAccountsPage({super.key}); - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => false, - child: Material( - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - S.of(context)!.switchingAccountsPleaseWait, - style: Theme.of(context).textTheme.labelLarge, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index c537abd3..8787acb4 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -21,6 +21,7 @@ class _LanguageSelectionSettingState extends State { 'tr': LanguageOption('Türkçe', true), 'pl': LanguageOption('Polska', true), 'ca': LanguageOption('Catalan', true), + 'ru': LanguageOption('Русский', true), }; @override @@ -34,9 +35,9 @@ class _LanguageSelectionSettingState extends State { onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( - footer: const Text( - "* Not fully translated yet. Some words may be displayed in English!", - ), + // footer: const Text( + // "* Not fully translated yet. Some words may be displayed in English!", + // ), titleText: S.of(context)!.language, options: [ for (var language in _languageOptions.entries) diff --git a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart index 0d035c68..473e488c 100644 --- a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart +++ b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class SkipDocumentPreprationOnShareSetting extends StatelessWidget { const SkipDocumentPreprationOnShareSetting({super.key}); @@ -9,9 +10,8 @@ class SkipDocumentPreprationOnShareSetting extends StatelessWidget { return GlobalSettingsBuilder( builder: (context, settings) { return SwitchListTile( - title: Text("Direct share"), - subtitle: - Text("Always directly upload when sharing files with the app."), + title: Text(S.of(context)!.skipEditingReceivedFiles), + subtitle: Text(S.of(context)!.uploadWithoutPromptingUploadForm), value: settings.skipDocumentPreprarationOnUpload, onChanged: (value) { settings.skipDocumentPreprarationOnUpload = value; diff --git a/lib/features/sharing/logic/upload_queue_processor.dart b/lib/features/sharing/logic/upload_queue_processor.dart deleted file mode 100644 index 89b06386..00000000 --- a/lib/features/sharing/logic/upload_queue_processor.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; -import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; -import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; -import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:path/path.dart' as p; - -class UploadQueueProcessor { - final ShareIntentQueue queue; - - UploadQueueProcessor({required this.queue}); - - bool _isFileTypeSupported(File file) { - final isSupported = - supportedFileExtensions.contains(p.extension(file.path)); - return isSupported; - } - - void processIncomingFiles( - BuildContext context, { - required List sharedFiles, - }) async { - if (sharedFiles.isEmpty) { - return; - } - Iterable files = sharedFiles.map((file) => File(file.path)); - if (Platform.isIOS) { - files = files - .map((file) => File(file.path.replaceAll('file://', ''))) - .toList(); - } - final supportedFiles = files.where(_isFileTypeSupported); - final unsupportedFiles = files.whereNot(_isFileTypeSupported); - debugPrint( - "Received ${files.length} files, out of which ${supportedFiles.length} are supported.}"); - if (supportedFiles.isEmpty) { - Fluttertoast.showToast( - msg: translateError( - context, - ErrorCode.unsupportedFileFormat, - ), - ); - if (Platform.isAndroid) { - // As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines. - await SystemNavigator.pop(); - } - return; - } - if (unsupportedFiles.isNotEmpty) { - //TODO: INTL - Fluttertoast.showToast( - msg: - "${unsupportedFiles.length}/${files.length} files could not be processed."); - } - await ShareIntentQueue.instance.addAll( - supportedFiles, - userId: context.read().id, - ); - } -} diff --git a/lib/features/sharing/model/share_intent_queue.dart b/lib/features/sharing/model/share_intent_queue.dart deleted file mode 100644 index 6bd29a21..00000000 --- a/lib/features/sharing/model/share_intent_queue.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'dart:collection'; -import 'dart:io'; - -import 'package:flutter/widgets.dart'; -import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:path/path.dart' as p; - -class ShareIntentQueue extends ChangeNotifier { - final Map> _queues = {}; - - ShareIntentQueue._(); - - static final instance = ShareIntentQueue._(); - - Future initialize() async { - final users = Hive.localUserAccountBox.values; - for (final user in users) { - final userId = user.id; - debugPrint("Locating remaining files to be uploaded for $userId..."); - final consumptionDir = - await FileService.getConsumptionDirectory(userId: userId); - final files = await FileService.getAllFiles(consumptionDir); - debugPrint( - "Found ${files.length} files to be uploaded for $userId. Adding to queue..."); - getQueue(userId).addAll(files); - } - } - - void add( - File file, { - required String userId, - }) => - addAll([file], userId: userId); - - Future addAll( - Iterable files, { - required String userId, - }) async { - if (files.isEmpty) { - return; - } - final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); - final copiedFiles = await Future.wait([ - for (var file in files) - file.copy('${consumptionDirectory.path}/${p.basename(file.path)}') - ]); - - debugPrint( - "Adding received files to queue: ${files.map((e) => e.path).join(",")}", - ); - getQueue(userId).addAll(copiedFiles); - notifyListeners(); - } - - /// Removes and returns the first item in the requested user's queue if it exists. - File? pop(String userId) { - if (hasUnhandledFiles(userId: userId)) { - final file = getQueue(userId).removeFirst(); - notifyListeners(); - return file; - // Don't notify listeners, only when new item is added. - } - return null; - } - - Future onConsumed(File file) { - debugPrint( - "File ${file.path} successfully consumed. Delelting local copy."); - return file.delete(); - } - - Future discard(File file) { - debugPrint("Discarding file ${file.path}."); - return file.delete(); - } - - /// Returns whether the queue of the requested user contains files waiting for processing. - bool hasUnhandledFiles({ - required String userId, - }) => - getQueue(userId).isNotEmpty; - - int unhandledFileCount({ - required String userId, - }) => - getQueue(userId).length; - - Queue getQueue(String userId) { - if (!_queues.containsKey(userId)) { - _queues[userId] = Queue(); - } - return _queues[userId]!; - } -} - -class UserAwareShareMediaFile { - final String userId; - final SharedMediaFile sharedFile; - - UserAwareShareMediaFile(this.userId, this.sharedFile); -} diff --git a/lib/features/sharing/view/consumption_queue_view.dart b/lib/features/sharing/view/consumption_queue_view.dart index e2d2617e..7b437b40 100644 --- a/lib/features/sharing/view/consumption_queue_view.dart +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; -import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/event_listener_shell.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; diff --git a/lib/features/sharing/view/widgets/upload_queue_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart similarity index 73% rename from lib/features/sharing/view/widgets/upload_queue_shell.dart rename to lib/features/sharing/view/widgets/event_listener_shell.dart index 59d8f142..33a44269 100644 --- a/lib/features/sharing/view/widgets/upload_queue_shell.dart +++ b/lib/features/sharing/view/widgets/event_listener_shell.dart @@ -3,19 +3,22 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; -import 'package:paperless_mobile/features/sharing/view/dialog/pending_files_info_dialog.dart'; import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -23,25 +26,33 @@ import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:path/path.dart' as p; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -class UploadQueueShell extends StatefulWidget { +class EventListenerShell extends StatefulWidget { final Widget child; - const UploadQueueShell({super.key, required this.child}); + const EventListenerShell({super.key, required this.child}); @override - State createState() => _UploadQueueShellState(); + State createState() => _EventListenerShellState(); } -class _UploadQueueShellState extends State { +class _EventListenerShellState extends State + with WidgetsBindingObserver { StreamSubscription? _subscription; + StreamSubscription? _documentDeletedSubscription; + Timer? _inboxTimer; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); _subscription = ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); context.read().addListener(_onTasksChanged); - + _documentDeletedSubscription = + context.read().$deleted.listen((event) { + showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); + }); + _listenToInboxChanges(); // WidgetsBinding.instance.addPostFrameCallback((_) async { // final notifier = context.read(); // await notifier.isInitialized; @@ -67,6 +78,52 @@ class _UploadQueueShellState extends State { // }); } + void _listenToInboxChanges() { + final cubit = context.read(); + final currentUser = context.read(); + if (!currentUser.paperlessUser.canViewInbox || _inboxTimer != null) { + return; + } + cubit.refreshItemsInInboxCount(false); + _inboxTimer = Timer.periodic(30.seconds, (_) { + cubit.refreshItemsInInboxCount(false); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _subscription?.cancel(); + _documentDeletedSubscription?.cancel(); + _inboxTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + debugPrint( + "App resumed, reloading connectivity and " + "restarting periodic query for inbox changes...", + ); + context.read().reload(); + _listenToInboxChanges(); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + default: + _inboxTimer?.cancel(); + _inboxTimer = null; + debugPrint( + "App either paused or hidden, stopping " + "periodic query for inbox changes.", + ); + break; + } + } + void _onTasksChanged() { final taskNotifier = context.read(); final userId = context.read().id; @@ -96,12 +153,6 @@ class _UploadQueueShellState extends State { } } - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return widget.child; @@ -167,9 +218,9 @@ Future consumeLocalFile( ); await consumptionNotifier.discardFile(file, userId: userId); - if (result.taskId != null) { - taskNotifier.listenToTaskChanges(result.taskId!); - } + // if (result.taskId != null) { + // taskNotifier.listenToTaskChanges(result.taskId!); + // } if (exitAppAfterConsumed) { SystemNavigator.pop(); } diff --git a/lib/features/tasks/model/pending_tasks_notifier.dart b/lib/features/tasks/model/pending_tasks_notifier.dart index b1e1f036..f99837f4 100644 --- a/lib/features/tasks/model/pending_tasks_notifier.dart +++ b/lib/features/tasks/model/pending_tasks_notifier.dart @@ -52,10 +52,10 @@ class PendingTasksNotifier extends ValueNotifier> { _subscriptions[taskId]?.cancel(); _subscriptions.remove(taskId); } else { - _subscriptions.forEach((key, value) { - value.cancel(); - _subscriptions.remove(key); - }); + for (var sub in _subscriptions.values) { + sub.cancel(); + } + _subscriptions.clear(); } } diff --git a/lib/helpers/file_helpers.dart b/lib/helpers/file_helpers.dart deleted file mode 100644 index 6c4577fc..00000000 --- a/lib/helpers/file_helpers.dart +++ /dev/null @@ -1,3 +0,0 @@ -String extractFilenameFromPath(String path) { - return path.split(RegExp('[./]')).reversed.skip(1).first; -} diff --git a/lib/helpers/image_helpers.dart b/lib/helpers/image_helpers.dart deleted file mode 100644 index 05e8de74..00000000 --- a/lib/helpers/image_helpers.dart +++ /dev/null @@ -1,38 +0,0 @@ -// Taken from https://github.com/flutter/flutter/issues/26127#issuecomment-782083060 -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -Future loadImage(ImageProvider provider) { - final config = ImageConfiguration( - bundle: rootBundle, - devicePixelRatio: window.devicePixelRatio, - platform: defaultTargetPlatform, - ); - final Completer completer = Completer(); - final ImageStream stream = provider.resolve(config); - - late final ImageStreamListener listener; - - listener = ImageStreamListener((ImageInfo image, bool sync) { - debugPrint("Image ${image.debugLabel} finished loading"); - completer.complete(); - stream.removeListener(listener); - }, onError: (dynamic exception, StackTrace? stackTrace) { - completer.complete(); - stream.removeListener(listener); - FlutterError.reportError(FlutterErrorDetails( - context: ErrorDescription('image failed to load'), - library: 'image resource service', - exception: exception, - stack: stackTrace, - silent: true, - )); - }); - - stream.addListener(listener); - return completer.future; -} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index e170bb7d..498a4486 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 015140ae..c631625f 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 97f090aa..5f947a6c 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Erneut versuchen", "discardFile": "Datei verwerfen?", - "discard": "Verwerfen" + "discard": "Verwerfen", + "backToLogin": "Zurück zur Anmeldung", + "skipEditingReceivedFiles": "Bearbeitung von empfangenen Dateien überspringen", + "uploadWithoutPromptingUploadForm": "Mit der App geteilte Dateien immer direkt hochladen, ohne das Upload-Formular anzuzeigen.", + "authenticatingDots": "Authentifizieren...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Nutzerinformationen werden gespeichert...", + "fetchingUserInformation": "Benutzerinformationen werden abgerufen...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Sitzung wird wiederhergestellt...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index db1778bf..a89ae967 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 3e0224f9..068a86f7 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 778733d0..da7ec87b 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 9153d4c2..151cf85d 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f89e99de..96e417e4 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 7fa97a49..0fb7f09d 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index bc36a56e..ef088231 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,6 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl_standalone.dart'; import 'package:local_auth/local_auth.dart'; -import 'package:mock_server/mock_server.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; @@ -28,7 +27,6 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -36,11 +34,8 @@ import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart' import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; -import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -50,12 +45,10 @@ import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/checking_login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/logging_out_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:paperless_mobile/theme.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -157,9 +150,8 @@ void main() async { apiFactory, sessionManager, connectivityStatusService, + localNotificationService, ); - await authenticationCubit.restoreSessionState(); - await ShareIntentQueue.instance.initialize(); runApp( MultiProvider( providers: [ @@ -208,11 +200,14 @@ class _GoRouterShellState extends State { @override void initState() { super.initState(); - FlutterNativeSplash.remove(); if (Platform.isAndroid) { _setOptimalDisplayMode(); } initializeDateFormatting(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + context.read().restoreSession(); + FlutterNativeSplash.remove(); + }); } /// Activates the highest supported refresh rate on the device. @@ -236,43 +231,96 @@ class _GoRouterShellState extends State { debugLogDiagnostics: kDebugMode, initialLocation: "/login", routes: [ - $loginRoute, - $verifyIdentityRoute, - $switchingAccountsRoute, - $logginOutRoute, - $checkingLoginRoute, ShellRoute( + pageBuilder: (context, state, child) { + return MaterialPage( + child: BlocListener( + listener: (context, state) { + switch (state) { + case UnauthenticatedState( + redirectToAccountSelection: var shouldRedirect + ): + if (shouldRedirect) { + const LoginToExistingAccountRoute().go(context); + } else { + const LoginRoute().go(context); + } + break; + case RestoringSessionState(): + const RestoringSessionRoute().go(context); + break; + case VerifyIdentityState(userId: var userId): + VerifyIdentityRoute(userId: userId).go(context); + break; + case SwitchingAccountsState(): + const SwitchingAccountsRoute().push(context); + break; + case AuthenticatedState(): + const LandingRoute().go(context); + break; + case AuthenticatingState state: + AuthenticatingRoute(state.currentStage.name).push(context); + break; + case LoggingOutState(): + const LoggingOutRoute().go(context); + break; + case AuthenticationErrorState(): + if (context.canPop()) { + context.pop(); + } + // LoginRoute( + // $extra: errorState.clientCertificate, + // password: errorState.password, + // serverUrl: errorState.serverUrl, + // username: errorState.username, + // ).go(context); + break; + } + }, + child: child, + ), + ); + }, navigatorKey: rootNavigatorKey, - builder: ProviderShellRoute(widget.apiFactory).build, routes: [ - $settingsRoute, - $savedViewsRoute, - $uploadQueueRoute, - StatefulShellRoute( - navigatorContainerBuilder: (context, navigationShell, children) { - return children[navigationShell.currentIndex]; - }, - builder: const ScaffoldShellRoute().builder, - branches: [ - StatefulShellBranch( - navigatorKey: landingNavigatorKey, - routes: [$landingRoute], - ), - StatefulShellBranch( - navigatorKey: documentsNavigatorKey, - routes: [$documentsRoute], - ), - StatefulShellBranch( - navigatorKey: scannerNavigatorKey, - routes: [$scannerRoute], - ), - StatefulShellBranch( - navigatorKey: labelsNavigatorKey, - routes: [$labelsRoute], - ), - StatefulShellBranch( - navigatorKey: inboxNavigatorKey, - routes: [$inboxRoute], + $loginRoute, + $loggingOutRoute, + $addAccountRoute, + ShellRoute( + navigatorKey: outerShellNavigatorKey, + builder: ProviderShellRoute(widget.apiFactory).build, + routes: [ + $settingsRoute, + $savedViewsRoute, + $uploadQueueRoute, + StatefulShellRoute( + navigatorContainerBuilder: + (context, navigationShell, children) { + return children[navigationShell.currentIndex]; + }, + builder: const ScaffoldShellRoute().builder, + branches: [ + StatefulShellBranch( + navigatorKey: landingNavigatorKey, + routes: [$landingRoute], + ), + StatefulShellBranch( + navigatorKey: documentsNavigatorKey, + routes: [$documentsRoute], + ), + StatefulShellBranch( + navigatorKey: scannerNavigatorKey, + routes: [$scannerRoute], + ), + StatefulShellBranch( + navigatorKey: labelsNavigatorKey, + routes: [$labelsRoute], + ), + StatefulShellBranch( + navigatorKey: inboxNavigatorKey, + routes: [$inboxRoute], + ), + ], ), ], ), @@ -283,69 +331,34 @@ class _GoRouterShellState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - switch (state) { - case UnauthenticatedState(): - _router.goNamed(R.login); - break; - case RequiresLocalAuthenticationState(): - _router.goNamed(R.verifyIdentity); - break; - case SwitchingAccountsState(): - final userId = context.read().id; - context - .read() - .cancelUserNotifications(userId); - _router.goNamed(R.switchingAccounts); - break; - case AuthenticatedState(): - _router.goNamed(R.landing); - break; - case CheckingLoginState(): - _router.goNamed(R.checkingLogin); - break; - case LogginOutState(): - final userId = context.read().id; - context - .read() - .cancelUserNotifications(userId); - _router.goNamed(R.loggingOut); - break; - case AuthenticationErrorState(): - _router.goNamed(R.login); - break; - } + return GlobalSettingsBuilder( + builder: (context, settings) { + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return MaterialApp.router( + routerConfig: _router, + debugShowCheckedModeBanner: true, + title: "Paperless Mobile", + theme: buildTheme( + brightness: Brightness.light, + dynamicScheme: lightDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + darkTheme: buildTheme( + brightness: Brightness.dark, + dynamicScheme: darkDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + themeMode: settings.preferredThemeMode, + supportedLocales: S.supportedLocales, + locale: Locale.fromSubtags( + languageCode: settings.preferredLocaleSubtag, + ), + localizationsDelegates: S.localizationsDelegates, + ); + }, + ); }, - child: GlobalSettingsBuilder( - builder: (context, settings) { - return DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) { - return MaterialApp.router( - routerConfig: _router, - debugShowCheckedModeBanner: true, - title: "Paperless Mobile", - theme: buildTheme( - brightness: Brightness.light, - dynamicScheme: lightDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - darkTheme: buildTheme( - brightness: Brightness.dark, - dynamicScheme: darkDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - themeMode: settings.preferredThemeMode, - supportedLocales: S.supportedLocales, - locale: Locale.fromSubtags( - languageCode: settings.preferredLocaleSubtag, - ), - localizationsDelegates: S.localizationsDelegates, - ); - }, - ); - }, - ), ); } } diff --git a/lib/routes/navigation_keys.dart b/lib/routes/navigation_keys.dart index 220cd4db..c99c21cb 100644 --- a/lib/routes/navigation_keys.dart +++ b/lib/routes/navigation_keys.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; final rootNavigatorKey = GlobalKey(); +final outerShellNavigatorKey = GlobalKey(); final landingNavigatorKey = GlobalKey(); final documentsNavigatorKey = GlobalKey(); final scannerNavigatorKey = GlobalKey(); diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 9df4834a..7f485992 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -2,9 +2,10 @@ class R { const R._(); static const landing = "landing"; static const login = "login"; + static const loginToExistingAccount = 'loginToExistingAccount'; static const documents = "documents"; static const verifyIdentity = "verifyIdentity"; - static const switchingAccounts = "switchingAccounts"; + static const switchingAccount = "switchingAccount"; static const savedView = "savedView"; static const createSavedView = "createSavedView"; static const editSavedView = "editSavedView"; @@ -21,6 +22,8 @@ class R { static const linkedDocuments = "linkedDocuments"; static const bulkEditDocuments = "bulkEditDocuments"; static const uploadQueue = "uploadQueue"; - static const checkingLogin = "checkingLogin"; + static const authenticating = "authenticating"; static const loggingOut = "loggingOut"; + static const restoringSession = "restoringSession"; + static const addAccount = 'addAccount'; } diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index d753128e..91346812 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -54,7 +54,8 @@ class DocumentsRoute extends GoRouteData { } class DocumentDetailsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final bool isLabelClickable; final DocumentModel $extra; @@ -86,7 +87,8 @@ class DocumentDetailsRoute extends GoRouteData { } class EditDocumentRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentModel $extra; @@ -114,7 +116,8 @@ class EditDocumentRoute extends GoRouteData { } class DocumentPreviewRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentModel $extra; final String? title; diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart index 44c0a6e9..fa81e77b 100644 --- a/lib/routes/typed/branches/labels_route.dart +++ b/lib/routes/typed/branches/labels_route.dart @@ -49,7 +49,8 @@ class LabelsRoute extends GoRouteData { } class EditLabelRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final Label $extra; @@ -67,7 +68,8 @@ class EditLabelRoute extends GoRouteData { } class CreateLabelRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final LabelType $extra; final String? name; @@ -88,7 +90,8 @@ class CreateLabelRoute extends GoRouteData { } class LinkedDocumentsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentFilter $extra; const LinkedDocumentsRoute(this.$extra); diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart index c7ce700a..ec72171c 100644 --- a/lib/routes/typed/branches/scanner_route.dart +++ b/lib/routes/typed/branches/scanner_route.dart @@ -52,7 +52,8 @@ class ScannerRoute extends GoRouteData { } class DocumentUploadRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final FutureOr $extra; final String? title; final String? filename; @@ -72,6 +73,7 @@ class DocumentUploadRoute extends GoRouteData { context.read(), context.read(), context.read(), + context.read(), ), child: DocumentUploadPreparationPage( title: title, diff --git a/lib/routes/typed/branches/upload_queue_route.dart b/lib/routes/typed/branches/upload_queue_route.dart index fa3327ae..77521e54 100644 --- a/lib/routes/typed/branches/upload_queue_route.dart +++ b/lib/routes/typed/branches/upload_queue_route.dart @@ -11,7 +11,8 @@ part 'upload_queue_route.g.dart'; name: R.uploadQueue, ) class UploadQueueRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; @override Widget build(BuildContext context, GoRouterState state) { diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart index 0a150b6d..32b4f28a 100644 --- a/lib/routes/typed/shells/provider_shell_route.dart +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -7,7 +7,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/features/home/view/home_shell_widget.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; -import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/event_listener_shell.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:provider/provider.dart'; @@ -50,7 +50,7 @@ import 'package:provider/provider.dart'; // ) class ProviderShellRoute extends ShellRouteData { final PaperlessApiFactory apiFactory; - static final GlobalKey $navigatorKey = rootNavigatorKey; + static final GlobalKey $navigatorKey = outerShellNavigatorKey; const ProviderShellRoute(this.apiFactory); @@ -77,7 +77,7 @@ class ProviderShellRoute extends ShellRouteData { child: ChangeNotifierProvider( create: (context) => ConsumptionChangeNotifier() ..loadFromConsumptionDirectory(userId: currentUserId), - child: UploadQueueShell(child: navigator), + child: EventListenerShell(child: navigator), ), ); } diff --git a/lib/routes/typed/top_level/add_account_route.dart b/lib/routes/typed/top_level/add_account_route.dart new file mode 100644 index 00000000..6c4375ee --- /dev/null +++ b/lib/routes/typed/top_level/add_account_route.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/view/add_account_page.dart'; +import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'add_account_route.g.dart'; + +@TypedGoRoute( + path: '/add-account', + name: R.addAccount, +) +class AddAccountRoute extends GoRouteData { + const AddAccountRoute(); + + static final $parentNavigatorKey = rootNavigatorKey; + @override + Widget build(BuildContext context, GoRouterState state) { + return AddAccountPage( + titleText: S.of(context)!.addAccount, + onSubmit: + (context, username, password, serverUrl, clientCertificate) async { + try { + final userId = await context.read().addAccount( + credentials: LoginFormCredentials( + username: username, + password: password, + ), + clientCertificate: clientCertificate, + serverUrl: serverUrl, + enableBiometricAuthentication: false, + locale: Localizations.localeOf(context).languageCode, + ); + final shoudSwitch = await showDialog( + context: context, + builder: (context) => const SwitchAccountDialog(), + ) ?? + false; + if (shoudSwitch) { + await context.read().switchAccount(userId); + } else { + while (context.canPop()) { + context.pop(); + } + } + } on PaperlessApiException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + // context.pop(); + } on PaperlessFormValidationException catch (exception, stackTrace) { + if (exception.hasUnspecificErrorMessage()) { + showLocalizedError(context, exception.unspecificErrorMessage()!); + // context.pop(); + } else { + showGenericError( + context, + exception.validationMessages.values.first, + stackTrace, + ); //TODO: Check if we can show error message directly on field here. + } + } on InfoMessageException catch (error) { + showInfoMessage(context, error); + // context.pop(); + } catch (unknownError, stackTrace) { + showGenericError(context, unknownError.toString(), stackTrace); + // context.pop(); + } + }, + submitText: S.of(context)!.addAccount, + ); + } +} diff --git a/lib/routes/typed/top_level/checking_login_route.dart b/lib/routes/typed/top_level/checking_login_route.dart deleted file mode 100644 index 1a9d62be..00000000 --- a/lib/routes/typed/top_level/checking_login_route.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'checking_login_route.g.dart'; - -@TypedGoRoute( - path: "/checking-login", - name: R.checkingLogin, -) -class CheckingLoginRoute extends GoRouteData { - const CheckingLoginRoute(); - @override - Widget build(BuildContext context, GoRouterState state) { - return Scaffold( - body: Center( - child: Text("Logging in..."), - ), - ); - } -} diff --git a/lib/routes/typed/top_level/logging_out_route.dart b/lib/routes/typed/top_level/logging_out_route.dart index d584be91..e78479ee 100644 --- a/lib/routes/typed/top_level/logging_out_route.dart +++ b/lib/routes/typed/top_level/logging_out_route.dart @@ -6,12 +6,12 @@ import 'package:paperless_mobile/routes/routes.dart'; part 'logging_out_route.g.dart'; -@TypedGoRoute( +@TypedGoRoute( path: "/logging-out", name: R.loggingOut, ) -class LogginOutRoute extends GoRouteData { - const LogginOutRoute(); +class LoggingOutRoute extends GoRouteData { + const LoggingOutRoute(); @override Widget build(BuildContext context, GoRouterState state) { return Scaffold( diff --git a/lib/routes/typed/top_level/login_route.dart b/lib/routes/typed/top_level/login_route.dart index ce6cc8fb..d8bf1461 100644 --- a/lib/routes/typed/top_level/login_route.dart +++ b/lib/routes/typed/top_level/login_route.dart @@ -1,10 +1,18 @@ import 'dart:async'; -import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart'; +import 'package:paperless_mobile/features/login/view/login_to_existing_account_page.dart'; +import 'package:paperless_mobile/features/login/view/verify_identity_page.dart'; +import 'package:paperless_mobile/features/login/view/widgets/login_transition_page.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; part 'login_route.g.dart'; @@ -12,12 +20,51 @@ part 'login_route.g.dart'; @TypedGoRoute( path: "/login", name: R.login, + routes: [ + TypedGoRoute( + path: "switching-account", + name: R.switchingAccount, + ), + TypedGoRoute( + path: 'authenticating', + name: R.authenticating, + ), + TypedGoRoute( + path: 'verify-identity', + name: R.verifyIdentity, + ), + TypedGoRoute( + path: 'existing', + name: R.loginToExistingAccount, + ), + TypedGoRoute( + path: 'restoring-session', + name: R.restoringSession, + ), + ], ) class LoginRoute extends GoRouteData { - const LoginRoute(); + static final $parentNavigatorKey = rootNavigatorKey; + final String? serverUrl; + final String? username; + final String? password; + final ClientCertificate? $extra; + + const LoginRoute({ + this.serverUrl, + this.username, + this.password, + this.$extra, + }); + @override Widget build(BuildContext context, GoRouterState state) { - return const LoginPage(); + return LoginPage( + initialServerUrl: serverUrl, + initialUsername: username, + initialPassword: password, + initialClientCertificate: $extra, + ); } @override @@ -28,3 +75,77 @@ class LoginRoute extends GoRouteData { return null; } } + +class SwitchingAccountsRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const SwitchingAccountsRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginTransitionPage( + text: S.of(context)!.switchingAccountsPleaseWait, + ); + } +} + +class AuthenticatingRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + final String checkLoginStageName; + const AuthenticatingRoute(this.checkLoginStageName); + @override + Widget build(BuildContext context, GoRouterState state) { + final stage = AuthenticatingStage.values.byName(checkLoginStageName); + final text = switch (stage) { + AuthenticatingStage.authenticating => S.of(context)!.authenticatingDots, + AuthenticatingStage.persistingLocalUserData => + S.of(context)!.persistingUserInformation, + AuthenticatingStage.fetchingUserInformation => + S.of(context)!.fetchingUserInformation, + }; + + return LoginTransitionPage(text: text); + } +} + +class VerifyIdentityRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + final String userId; + const VerifyIdentityRoute({required this.userId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return VerifyIdentityPage(userId: userId); + } +} + +class LoginToExistingAccountRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const LoginToExistingAccountRoute(); + + @override + FutureOr redirect(BuildContext context, GoRouterState state) { + if (Hive.localUserAccountBox.isEmpty) { + return "/login"; + } + return null; + } + + @override + Widget build(BuildContext context, GoRouterState state) { + return const LoginToExistingAccountPage(); + } +} + +class RestoringSessionRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const RestoringSessionRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginTransitionPage(text: S.of(context)!.restoringSession); + } +} diff --git a/lib/routes/typed/top_level/settings_route.dart b/lib/routes/typed/top_level/settings_route.dart index 3286c00d..8a93e746 100644 --- a/lib/routes/typed/top_level/settings_route.dart +++ b/lib/routes/typed/top_level/settings_route.dart @@ -13,7 +13,7 @@ part 'settings_route.g.dart'; name: R.settings, ) class SettingsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; @override Widget build(BuildContext context, GoRouterState state) { diff --git a/lib/routes/typed/top_level/switching_accounts_route.dart b/lib/routes/typed/top_level/switching_accounts_route.dart deleted file mode 100644 index 1dc74dc1..00000000 --- a/lib/routes/typed/top_level/switching_accounts_route.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'switching_accounts_route.g.dart'; - -@TypedGoRoute( - path: '/switching-accounts', - name: R.switchingAccounts, -) -class SwitchingAccountsRoute extends GoRouteData { - const SwitchingAccountsRoute(); - @override - Widget build(BuildContext context, GoRouterState state) { - return const SwitchingAccountsPage(); - } -} diff --git a/lib/routes/typed/top_level/verify_identity_route.dart b/lib/routes/typed/top_level/verify_identity_route.dart deleted file mode 100644 index 5e62dd10..00000000 --- a/lib/routes/typed/top_level/verify_identity_route.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:go_router/go_router.dart'; -import 'package:flutter/widgets.dart'; -import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'verify_identity_route.g.dart'; - -@TypedGoRoute( - path: '/verify-identity', - name: R.verifyIdentity, -) -class VerifyIdentityRoute extends GoRouteData { - const VerifyIdentityRoute(); - - @override - Widget build(BuildContext context, GoRouterState state) { - return const VerifyIdentityPage(); - } -} diff --git a/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart index 55993087..b300b54a 100644 --- a/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart +++ b/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart @@ -20,7 +20,8 @@ class PaperlessFormValidationException implements Exception { } static bool canParse(Map json) { - return json.values.every((element) => element is String); + return json.values + .every((element) => element is String || element is List); } factory PaperlessFormValidationException.fromJson(Map json) { diff --git a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart index 631fbc3a..3ca527ad 100644 --- a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart @@ -20,13 +20,15 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { "password": password, }, options: Options( + sendTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), followRedirects: false, headers: { "Accept": "application/json", }, - validateStatus: (status) { - return status! == 200; - }, + // validateStatus: (status) { + // return status! == 200; + // }, ), ); return response.data['token']; diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index 4227f832..09560aa9 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart @@ -29,7 +29,7 @@ abstract class PaperlessDocumentsApi { DocumentModel document, String localFilePath, { bool original = false, - void Function(double)? onProgressChanged, + void Function(double progress)? onProgressChanged, }); Future findSuggestions(DocumentModel document); 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 016a2604..25aabaa3 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 @@ -23,6 +23,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { int? correspondent, Iterable tags = const [], int? asn, + void Function(double progress)? onProgressChanged, }) async { final formData = FormData(); formData.files.add( @@ -55,7 +56,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { '/api/documents/post_document/', data: formData, onSendProgress: (count, total) { - debugPrint("Uploading ${(count / total) * 100}%..."); + onProgressChanged?.call(count.toDouble() / total.toDouble()); }, options: Options(validateStatus: (status) => status == 200), ); diff --git a/pubspec.yaml b/pubspec.yaml index a77281d1..1e5f8564 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.3.12+47 +version: 3.0.0+47 environment: sdk: ">=3.0.0 <4.0.0" @@ -50,7 +50,7 @@ dependencies: cached_network_image: ^3.2.1 shimmer: ^2.0.0 flutter_bloc: ^8.1.1 - equatable: ^2.0.3 + equatable: ^2.0.5 flutter_form_builder: ^8.0.0 package_info_plus: ^4.0.1 font_awesome_flutter: ^10.1.0