diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1e1dd1..4001596 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ + android:requestLegacyExternalStorage="true" + > \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml index d086c08..51718af 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,3 +1,4 @@ +project_id: "568557" files: [ { "source" : "/lib/l10n/intl_en.arb", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0c86e09..1dc5690 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/core/bloc/document_status_cubit.dart b/lib/core/bloc/document_status_cubit.dart deleted file mode 100644 index 84121dd..0000000 --- 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/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart index c0d8f7b..6557f20 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/config/hive/hive_config.dart @@ -15,11 +15,9 @@ 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'; - 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 7a6c0e2..c519dcd 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/config/hive/hive_extensions.dart @@ -4,13 +4,19 @@ 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'; /// /// Opens an encrypted box, calls [callback] with the now opened box, awaits /// [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 +28,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(); @@ -34,3 +44,14 @@ 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 globalSettingsBox => + box(HiveBoxes.globalSettings); +} diff --git a/lib/core/database/tables/global_settings.dart b/lib/core/database/tables/global_settings.dart index 6ee55c9..fdcccbc 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; @@ -32,14 +32,18 @@ class GlobalSettings with HiveObjectMixin { @HiveField(7, defaultValue: false) bool enforceSinglePagePdfUpload; + @HiveField(8, defaultValue: false) + bool skipDocumentPreprarationOnUpload; + GlobalSettings({ required this.preferredLocaleSubtag, 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, + this.skipDocumentPreprarationOnUpload = false, }); } diff --git a/lib/core/database/tables/local_user_account.dart b/lib/core/database/tables/local_user_account.dart index 64e6cc4..d54d890 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_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'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; part 'local_user_account.g.dart'; @@ -20,16 +19,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 687eb3a..49812e2 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/global/constants.dart b/lib/core/global/constants.dart index 652572f..04230a7 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/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 2d6a0ba..6c8fb1b 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 0000000..817954e --- /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 deleted file mode 100644 index 82bf09a..0000000 --- a/lib/core/navigation/push_routes.dart +++ /dev/null @@ -1,391 +0,0 @@ -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: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_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/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'; -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. -// 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()! - .currentLoggedInUser; - 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, - ), - ), - ), - ); -} - -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 (apiVersion.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, - savedView: savedView, - ), - child: SavedViewDetailsPage( - onDelete: context.read().remove), - ); - }, - ), - ), - ); -} - -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 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.read().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, -}) { - 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); - }, - ); - }, - ), - ), - ), - ), - ); -} - -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()), - 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 04a9781..e1c2bba 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/repository/persistent_repository.dart b/lib/core/repository/persistent_repository.dart index db63c13..8df0084 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/repository/saved_view_repository.dart b/lib/core/repository/saved_view_repository.dart index 234ceb3..09fb82d 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/security/session_manager.dart b/lib/core/security/session_manager.dart index 8281f2a..8f2aba3 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 0908e48..6ce404b 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_description.dart b/lib/core/service/file_description.dart deleted file mode 100644 index 5b0a81c..0000000 --- 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 881452d..32482a2 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,34 +1,30 @@ 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, ) 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); } static Future getDirectory(PaperlessDirectoryType type) { - switch (type) { - case PaperlessDirectoryType.documents: - return documentsDirectory; - case PaperlessDirectoryType.temporary: - return temporaryDirectory; - case PaperlessDirectoryType.scans: - return scanDirectory; - 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( @@ -43,17 +39,16 @@ 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, ))! .first; } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/documents'); - dir.createSync(); - return dir; + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/documents')); + return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); } @@ -72,34 +67,38 @@ 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 uploadDirectory async { + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/upload')); + return dir.create(recursive: true); } - static Future clearUserData() async { - final scanDir = await scanDirectory; + 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; - await scanDir?.delete(recursive: true); + final scansDir = Directory('${tempDir.path}/scans'); + return scansDir.create(recursive: true); + } + + 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 { @@ -113,11 +112,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/service/status_service.dart b/lib/core/service/status_service.dart deleted file mode 100644 index ea16bee..0000000 --- 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/translation/error_code_localization_mapper.dart b/lib/core/translation/error_code_localization_mapper.dart index 24d938c..ac1204e 100644 --- a/lib/core/translation/error_code_localization_mapper.dart +++ b/lib/core/translation/error_code_localization_mapper.dart @@ -54,24 +54,27 @@ 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.", + S.of(context)!.couldNotDeleteCorrespondent, ErrorCode.documentTypeDeleteFailed => - "Could not delete document type, please try again.", - ErrorCode.tagDeleteFailed => "Could not delete tag, please try again.", - ErrorCode.correspondentUpdateFailed => - "Could not update correspondent, please try again.", - ErrorCode.documentTypeUpdateFailed => - "Could not update document type, please try again.", - ErrorCode.tagUpdateFailed => "Could not update tag, please try again.", + S.of(context)!.couldNotDeleteDocumentType, + ErrorCode.tagDeleteFailed => S.of(context)!.couldNotDeleteTag, ErrorCode.storagePathDeleteFailed => - "Could not delete storage path, please try again.", + S.of(context)!.couldNotDeleteStoragePath, + ErrorCode.correspondentUpdateFailed => + S.of(context)!.couldNotUpdateCorrespondent, + ErrorCode.documentTypeUpdateFailed => + 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.", + 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, + ErrorCode.userAlreadyExists => S.of(context)!.userAlreadyExists, }; } 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 91b6bee..0000000 --- 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/dialog_utils/pop_with_unsaved_changes.dart b/lib/core/widgets/dialog_utils/pop_with_unsaved_changes.dart new file mode 100644 index 0000000..b171007 --- /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 0000000..8846031 --- /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 2b179ed..0000000 --- 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/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 bbd8480..0000000 --- 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/future_or_builder.dart b/lib/core/widgets/future_or_builder.dart new file mode 100644 index 0000000..4651f5d --- /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/core/widgets/material/chips_input.dart b/lib/core/widgets/material/chips_input.dart deleted file mode 100644 index 7369a0c..0000000 --- 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/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 48adf98..61b41bb 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -1,16 +1,19 @@ -import 'package:flutter/cupertino.dart'; 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_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/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 { @@ -21,6 +24,7 @@ class AppDrawer extends StatelessWidget { return SafeArea( child: Drawer( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ @@ -60,56 +64,126 @@ 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, ); }, ), + 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("Pending Files"), + 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), 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), ), + 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_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 9f740b8..0000000 --- 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/fullscreen_bulk_edit_label_page.dart b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart index 2d2a7a6..33dee5e 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 1248521..dbeb107 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_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 8bde625..0000000 --- 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 3ad7007..2779863 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -8,13 +8,12 @@ import 'package:open_filex/open_filex.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/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'; @@ -45,7 +44,6 @@ class DocumentDetailsCubit extends Cubit { ), ), ); - loadSuggestions(); loadMetaData(); } @@ -54,13 +52,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) { @@ -101,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()) { @@ -128,51 +117,63 @@ class DocumentDetailsCubit extends Cubit { Future downloadDocument({ bool downloadOriginal = false, required String locale, + required String userId, }) async { 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, - ); + // 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 { @@ -223,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/cubit/document_details_state.dart b/lib/features/document_details/cubit/document_details_state.dart index 54a3751..6bcf0e7 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 8732da8..2ad12d6 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'; @@ -14,16 +15,14 @@ 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'; -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'; +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'; 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 && false ? 1 : 0); return WillPopScope( onWillPop: () async { Navigator.of(context) @@ -86,45 +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: 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, - ), + 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( + 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( @@ -171,7 +177,7 @@ class _DocumentDetailsPageState extends State { ), ), ), - if (apiVersion.hasMultiUserSupport) + if (hasMultiUserSupport && false) Tab( child: Text( "Permissions", @@ -195,6 +201,7 @@ class _DocumentDetailsPageState extends State { context.read(), context.read(), context.read(), + context.read(), documentId: state.document.id, ), child: Padding( @@ -259,7 +266,7 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (apiVersion.hasMultiUserSupport) + if (hasMultiUserSupport && false) CustomScrollView( controller: _pagingScrollController, slivers: [ @@ -286,8 +293,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(); } @@ -301,8 +310,9 @@ class _DocumentDetailsPageState extends State { preferBelow: false, verticalOffset: 40, child: FloatingActionButton( + heroTag: "fab_document_details", child: const Icon(Icons.edit), - onPressed: () => _onEdit(state.document), + onPressed: () => EditDocumentRoute(state.document).push(context), ), ); }, @@ -315,38 +325,48 @@ class _DocumentDetailsPageState extends State { return BottomAppBar( child: BlocBuilder( builder: (context, connectivityState) { - final isConnected = connectivityState.isConnected; - - final canDelete = isConnected && - LocalUserAccount.current.paperlessUser.canDeleteDocuments; + final currentUser = context.watch(); 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), ), - //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), - onPressed: isConnected ? _onOpenFileInSystemViewer : null, - ).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), @@ -360,47 +380,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(); @@ -427,25 +406,21 @@ 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 { - // 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/archive_serial_number_field.dart b/lib/features/document_details/view/widgets/archive_serial_number_field.dart index 1608010..ba2f6e2 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_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 9d34831..5f66073 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_details/view/widgets/document_meta_data_widget.dart b/lib/features/document_details/view/widgets/document_meta_data_widget.dart index 8eae4f8..bdfcf81 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 3e6804e..f38d266 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'; @@ -30,62 +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 && - LocalUserAccount.current.paperlessUser.canViewDocumentTypes) - DetailsItem( - label: S.of(context)!.documentType, - content: LabelText( - style: Theme.of(context).textTheme.bodyLarge, - label: availableDocumentTypes[document.documentType], + 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), - if (document.correspondent != null && - LocalUserAccount.current.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 && - LocalUserAccount.current.paperlessUser.canViewStoragePaths) - DetailsItem( - label: S.of(context)!.storagePath, - content: LabelText( - label: availableStoragePaths[document.storagePath], - ), - ).paddedOnly(bottom: itemSpacing), - if (document.tags.isNotEmpty && - LocalUserAccount.current.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_details/view/widgets/document_share_button.dart b/lib/features/document_details/view/widgets/document_share_button.dart index 257ac6b..aaeb0c0 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_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index 966302f..84195dd 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 6504095..0f1bb39 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 ec83909..f974eb7 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -4,11 +4,12 @@ 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'; 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'; @@ -18,14 +19,11 @@ 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 { - final FieldSuggestions? suggestions; const DocumentEditPage({ Key? key, - required this.suggestions, }) : super(key: key); @override @@ -42,256 +40,261 @@ class _DocumentEditPageState extends State { static const fkContent = 'content'; 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) { - return DefaultTabController( - length: 2, - child: Scaffold( - resizeToAvoidBottomInset: false, - floatingActionButton: FloatingActionButton.extended( - 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, - ) - ], + final currentUser = context.watch().paperlessUser; + 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, - ), - child: FormBuilder( - key: _formKey, - child: TabBarView( - children: [ - ListView( - children: [ - _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField(state.document.created) - .padded(), - // Correspondent form field - 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: LocalUserAccount.current - .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), - ); - }, - ), - ), - ], - ).padded(), - // DocumentType form field - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (currentInput) => - RepositoryProvider.value( - value: context.read(), - child: AddDocumentTypePage( - initialName: currentInput, - ), - ), - canCreateNewLabel: LocalUserAccount.current - .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), - ), - ), - ), - ], - ).padded(), - // StoragePath form field - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - addLabelPageBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddStoragePathPage( - initalName: initialValue), - ), - canCreateNewLabel: LocalUserAccount.current - .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(), - // 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 (_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, - ), - ), - const SizedBox(height: 84), - ], - ), - ), + appBar: AppBar( + title: Text(S.of(context)!.editDocument), + bottom: TabBar( + tabs: [ + Tab(text: S.of(context)!.overview), + Tab(text: S.of(context)!.content) ], ), ), - )), - ); - }, + 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, + ), + 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, + ), + ), + 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), + ), + ), + ), + ], + ).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(), + 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, + ), + ), + const SizedBox(height: 84), + ], + ), + ), + ], + ), + ), + )), + ); + }, + ), ); } @@ -301,28 +304,23 @@ 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(() { - _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; - }); - Navigator.pop(context); + context.pop(); } } } @@ -343,7 +341,8 @@ class _DocumentEditPageState extends State { ); } - Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) { + Widget _buildCreatedAtFormField( + DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -358,9 +357,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_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 3e0b9a5..de54d25 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()); - void addScan(File file) => emit([...state, file]); - - void removeScan(int fileIndex) { - try { - state[fileIndex].deleteSync(); - final scans = [...state]; - scans.removeAt(fileIndex); - emit(scans); - } catch (_) { - throw const PaperlessApiException(ErrorCode.scanRemoveFailed); - } + 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 reset() { + void addScan(File file) async { + emit(LoadedDocumentScannerState( + scans: [...state.scans, file], + )); + } + + Future removeScan(File file) async { try { - for (final doc in state) { - doc.deleteSync(); - if (kDebugMode) { - log('[ScannerCubit]: Removed ${doc.path}'); - } - } + 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), + ); + } + + Future reset() async { + try { + 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 0000000..70f7b33 --- /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/logic/services/decode.isolate.dart b/lib/features/document_scan/logic/services/decode.isolate.dart deleted file mode 100644 index 91196be..0000000 --- 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 71aed2b..729c8c0 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -10,23 +10,22 @@ 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'; -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'; import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; 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/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'; import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; @@ -51,71 +50,54 @@ class _ScannerPageState extends State @override 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>( + 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 SafeArea( - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: FloatingActionButton( - onPressed: () => _openDocumentScanner(context), - child: const Icon(Icons.add_a_photo_outlined), + return switch (state) { + InitialDocumentScannerState() => _buildEmptyState(), + RestoringDocumentScannerState() => Center( + child: Text("Restoring..."), ), - 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); - } - }, - ), - ), - ), - ); + 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), @@ -134,12 +116,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), ), @@ -154,19 +136,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( @@ -174,7 +169,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), @@ -196,7 +191,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 = @@ -253,31 +248,27 @@ 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, ); - final uploadResult = await pushDocumentUploadPreparationPage( - context, - bytes: file.bytes, + final uploadResult = await DocumentUploadRoute( + $extra: file.bytes, fileExtension: file.extension, - ); - if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) { + ).push(context); + 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!); } } - Widget _buildEmptyState(bool isConnected, List scans) { - if (scans.isNotEmpty) { - return _buildImageGrid(scans); - } + Widget _buildEmptyState() { return Center( child: Padding( padding: const EdgeInsets.all(8.0), @@ -293,9 +284,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, + ), ), ], ), @@ -323,7 +320,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); } @@ -349,30 +348,34 @@ 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), ); return; } - pushDocumentUploadPreparationPage( - context, - bytes: file.readAsBytesSync(), - filename: fileDescription.filename, - title: fileDescription.filename, - fileExtension: fileDescription.extension, - ); + DocumentUploadRoute( + $extra: file.readAsBytesSync(), + 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 61f045b..657475f 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'; @@ -15,6 +16,8 @@ class DocumentSearchCubit extends Cubit with DocumentPagingBlocMixin { @override final PaperlessDocumentsApi api; + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -24,8 +27,11 @@ class DocumentSearchCubit extends Cubit this.api, this.notifier, this._userAppState, - ) : super(DocumentSearchState( - searchHistory: _userAppState.documentSearchHistory)) { + this.connectivityStatusService, + ) : super( + DocumentSearchState( + searchHistory: _userAppState.documentSearchHistory), + ) { notifier.addListener( this, onDeleted: remove, @@ -34,22 +40,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 7b4327b..bbc3bd5 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -1,19 +1,15 @@ 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/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'; @@ -27,112 +23,96 @@ 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, + 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: ListenableBuilder( + listenable: + context.read(), + builder: (context, child) { + return Badge( + isLabelVisible: context + .read() + .pendingFiles + .isNotEmpty, + child: const Icon(Icons.menu), + backgroundColor: Colors.red, + smallSize: 8, + ); + }, ), - Flexible( - child: Text( - S.of(context)!.searchDocuments, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - fontWeight: FontWeight.w500, - color: Theme.of(context).hintColor, - ), - ), + 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 MultiProvider( - providers: [ - 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: Provider( - create: (_) => DocumentSearchCubit( - context.read(), - context.read(), - Hive.box(HiveBoxes.localUserAppState) - .get(LocalUserAccount.current.id)!, - ), - builder: (_, __) => const DocumentSearchPage(), - ), - ); - }, - ), + ), + ); + }, + openBuilder: (_, action) { + return Provider( + create: (_) => DocumentSearchCubit( + context.read(), + context.read(), + Hive.box(HiveBoxes.localUserAppState) + .get(context.read().id)!, + context.read(), + ), + child: const DocumentSearchPage(), + ); + }, ); } 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( context: context, - builder: (context) => Provider.value( - value: apiVersion, - child: const ManageAccountsPage(), - ), + builder: (_) => const ManageAccountsPage(), ); }, ); diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index f949891..6ae14f2 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,13 +4,13 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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'; 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}); @@ -186,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) { @@ -198,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( @@ -218,11 +218,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 67487b9..8cea3d9 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'; @@ -25,14 +22,11 @@ class SliverSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { - if (LocalUserAccount.current.paperlessUser.canViewDocuments) { - return SliverAppBar( - toolbarHeight: kToolbarHeight, - flexibleSpace: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0), - child: const DocumentSearchBar(), - ), + if (context.watch().paperlessUser.canViewDocuments) { + return const SliverAppBar( + titleSpacing: 8, automaticallyImplyLeading: false, + title: DocumentSearchBar(), ); } else { return SliverAppBar( @@ -49,18 +43,17 @@ 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); }, ); }, ), 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/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index ae275a5..86019a9 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -1,24 +1,26 @@ 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'; +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 Connectivity _connectivity; + final ConnectivityStatusService _connectivityStatusService; DocumentUploadCubit( this._labelRepository, this._documentApi, - this._connectivity, + 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 a1537ce..a2113d5 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -1,10 +1,11 @@ +import 'dart:async'; 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:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -12,6 +13,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'; @@ -19,8 +21,8 @@ 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'; import 'package:provider/provider.dart'; @@ -32,7 +34,7 @@ class DocumentUploadResult { } class DocumentUploadPreparationPage extends StatefulWidget { - final Uint8List fileBytes; + final FutureOr fileBytes; final String? title; final String? filename; final String? fileExtension; @@ -56,7 +58,6 @@ class _DocumentUploadPreparationPageState static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss"); final GlobalKey _formKey = GlobalKey(); - Map _errors = {}; bool _isUploadLoading = false; late bool _syncTitleAndFilename; @@ -73,18 +74,12 @@ class _DocumentUploadPreparationPageState @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,174 +89,249 @@ 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(), + pinned: true, + expandedHeight: 150, + flexibleSpace: FlexibleSpaceBar( + background: FutureOrBuilder( + future: widget.fileBytes, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return SizedBox.shrink(); + } + return FileThumbnail( + bytes: snapshot.data!, + fit: BoxFit.fitWidth, + width: MediaQuery.sizeOf(context).width, + ); + }, + ), + title: Text(S.of(context)!.prepareDocument), + collapseMode: CollapseMode.pin, ), - 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); - }, + bottom: _isUploadLoading + ? PreferredSize( + child: LinearProgressIndicator(), + preferredSize: Size.fromHeight(4.0), ) : null, ), ), - // Correspondent - if (LocalUserAccount - .current.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: LocalUserAccount - .current.paperlessUser.canCreateCorrespondents, - ), - // Document type - if (LocalUserAccount.current.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: LocalUserAccount - .current.paperlessUser.canCreateDocumentTypes, - ), - if (LocalUserAccount.current.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(), + ), ), ); }, @@ -289,14 +359,14 @@ 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, ), userId: Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser!, + .loggedInUserId!, title: title, documentType: docType, correspondent: correspondent, @@ -308,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) { @@ -336,4 +403,33 @@ class _DocumentUploadPreparationPageState String _formatFilename(String source) { 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; + + // 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/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index 218b8a6..da1add1 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 4cdbd32..9689a44 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,28 +1,31 @@ -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/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/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'; +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/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'; +import 'package:sliver_tools/sliver_tools.dart'; class DocumentFilterIntent { final DocumentFilter? filter; @@ -41,283 +44,260 @@ class DocumentsPage extends StatefulWidget { State createState() => _DocumentsPageState(); } -class _DocumentsPageState extends State - with SingleTickerProviderStateMixin { +class _DocumentsPageState extends State { final SliverOverlapAbsorberHandle searchBarHandle = SliverOverlapAbsorberHandle(); - final SliverOverlapAbsorberHandle tabBarHandle = - SliverOverlapAbsorberHandle(); - late final TabController _tabController; - int _currentTab = 0; + final SliverOverlapAbsorberHandle savedViewsHandle = + SliverOverlapAbsorberHandle(); + + final _nestedScrollViewKey = GlobalKey(); + + final _savedViewsExpansionController = ExpansionTileController(); + bool _showExtendedFab = true; @override void initState() { super.initState(); - final showSavedViews = - LocalUserAccount.current.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); + // context.read().addListener(_onTasksChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + _nestedScrollViewKey.currentState!.innerController + .addListener(_scrollExtentChangedListener); + }); } - void _tabChangesListener() { - setState(() => _currentTab = _tabController.index); + 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(), + 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); + } + } + + 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); + // 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) { - try { - context.read().reload(); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - }, - builder: (context, connectivityState) { - return SafeArea( - top: true, - child: Scaffold( - 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( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (canReset) - Padding( - padding: const EdgeInsets.all(8.0), - child: FloatingActionButton.small( - key: UniqueKey(), - backgroundColor: Theme.of(context) - .colorScheme - .onPrimaryContainer, - onPressed: () { - context.read().updateFilter(); - }, - child: Icon( - Icons.refresh, - color: Theme.of(context) - .colorScheme - .primaryContainer, + 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, ), - ), - 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: (_currentTab == 0) - ? FloatingActionButton( - child: - const Icon(Icons.filter_alt_outlined), - onPressed: _openDocumentFilter, - ) - : FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () => - _onCreateSavedView(state.filter), + 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), + ], + ), + ), ), - ), - ), - ], - ), - ); - }, - ), - resizeToAvoidBottomInset: true, - body: WillPopScope( - onWillPop: () async { - if (context - .read() - .state - .selection - .isNotEmpty) { - context.read().resetSelection(); - return false; - } - 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, - ); - } - }, - ), - ), - 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 (LocalUserAccount.current.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 (LocalUserAccount - .current.paperlessUser.canViewSavedViews) - Builder( - builder: (context) { - return _buildSavedViewsTab( - connectivityState, - context, - ); - }, - ), - ], + ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + resizeToAvoidBottomInset: true, + body: WillPopScope( + onWillPop: () async { + 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; + }, + 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, + ), + ), + ), + ], + body: _buildDocumentsTab( + connectivityState, + context, ), ), ), - ); - }, - ), - ); - } - - 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: tabBarHandle, - ), - const SavedViewList(), - ], - ), + ); + }, ); } @@ -329,17 +309,19 @@ 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; + final currentState = context.read().state; if (max == 0 || - _currentTab != 0 || - currState.isLoading || - currState.isLastPageLoaded) { + currentState.isLoading || + currentState.isLastPageLoaded) { return false; } - final offset = notification.metrics.pixels; if (offset >= max * 0.7) { context .read() @@ -356,29 +338,77 @@ class _DocumentsPageState extends State return false; }, child: RefreshIndicator( - edgeOffset: kTextTabBarHeight, - onRefresh: _onReloadDocuments, + edgeOffset: kTextTabBarHeight + 2, + onRefresh: _reloadData, notificationPredicate: (_) => connectivityState.isConnected, child: CustomScrollView( key: const PageStorageKey("documents"), slivers: [ SliverOverlapInjector(handle: searchBarHandle), - SliverOverlapInjector(handle: tabBarHandle), - _buildViewActions(), + SliverOverlapInjector(handle: savedViewsHandle), + 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(); + if (state.filter.selectedView == view.id) { + _onResetFilter(); + } else { + cubit.updateFilter( + filter: view.toDocumentFilter(), + ); + } + }, + onUpdateView: (view) async { + await context.read().update(view); + showSnackBar( + context, S.of(context)!.savedViewSuccessfullyUpdated); + }, + 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, + ); + }, + ), + ), BlocBuilder( builder: (context, state) { if (state.hasLoaded && state.documents.isEmpty) { return SliverToBoxAdapter( child: DocumentsEmptyState( state: state, - onReset: context.read().resetFilter, + onReset: _onResetFilter, ), ); } 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, @@ -404,10 +434,12 @@ class _DocumentsPageState extends State } Widget _buildViewActions() { - return SliverToBoxAdapter( - child: BlocBuilder( - builder: (context, state) { - return Row( + return BlocBuilder( + builder: (context, state) { + return Container( + padding: const EdgeInsets.all(4), + color: Theme.of(context).colorScheme.background, + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SortDocumentsButton( @@ -418,23 +450,12 @@ 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); - } - } - } - void _openDocumentFilter() async { final draggableSheetController = DraggableScrollableController(); final filterIntent = await showModalBottomSheet( @@ -476,7 +497,7 @@ class _DocumentsPageState extends State if (filterIntent != null) { try { if (filterIntent.shouldReset) { - await context.read().resetFilter(); + await _onResetFilter(); } else { await context .read() @@ -488,13 +509,6 @@ class _DocumentsPageState extends State } } - void _openDetails(DocumentModel document) { - pushDocumentDetailsRoute( - context, - document: document, - ); - } - void _addTagToFilter(int tagId) { final cubit = context.read(); try { @@ -632,21 +646,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 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(); - 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(); - } on PaperlessApiException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); + 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; + }, + ); + final viewHasChanged = activeView != null && + activeView.toDocumentFilter() != cubit.state.filter; + if (viewHasChanged) { + final discardChanges = await showDialog( + context: context, + builder: (context) => const SavedViewChangedDialog(), + ) ?? + false; + if (discardChanges) { + cubit.resetFilter(); + toTop(); + } + } else { + cubit.resetFilter(); + toTop(); } } } diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 74da008..623e864 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -2,6 +2,8 @@ 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'; @@ -12,6 +14,7 @@ class DocumentPreview extends StatelessWidget { final double borderRadius; final bool enableHero; final double scale; + final bool isClickable; const DocumentPreview({ super.key, @@ -21,15 +24,26 @@ 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 ConnectivityAwareActionWrapper( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: isClickable + ? () => DocumentPreviewRoute($extra: document).push(context) + : null, + 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/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 981d87b..7482265 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/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index 3f214ae..8fa60df 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/items/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart index 9333610..3dcc917 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 1736eaf..11c8d6a 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, @@ -29,91 +31,90 @@ class DocumentListItem extends DocumentItem { @override Widget build(BuildContext context) { final labels = context.watch().state; - return Material( - child: ListTile( - 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), ), ), - ), - 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), + ], ), + 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, + ), + ), + ), + contentPadding: const EdgeInsets.all(8.0), ); } 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 042f692..0000000 --- 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/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 0000000..116eb37 --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart @@ -0,0 +1,25 @@ +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(S.of(context)!.discardChanges), + content: Text(S.of(context)!.savedViewChangedDialogContent), + actionsOverflowButtonSpacing: 8, + actions: [ + const DialogCancelButton(), + 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 0000000..12dbd3b --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart @@ -0,0 +1,165 @@ +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/routes/typed/branches/saved_views_route.dart'; + +class SavedViewChip extends StatefulWidget { + final SavedView view; + final void Function(SavedView view) onViewSelected; + final void Function(SavedView view) onUpdateView; + final void Function(SavedView view) onDeleteView; + 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, + 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) { + 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, + ), + ], + ).paddedOnly(left: 8), + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: _isExpanded ? expandedChild : const SizedBox.shrink(), + ), + _buildTrailing(effectiveForegroundColor), + ], + ), + ), + ), + ); + } + + Widget _buildTrailing(Color effectiveForegroundColor) { + return IconButton( + padding: EdgeInsets.zero, + 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 new file mode 100644 index 0000000..56ee187 --- /dev/null +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -0,0 +1,236 @@ +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/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'; +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 { + 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 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)!.youDidNotSaveAnyViewsYet) + .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 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) => + 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: ConnectivityAwareActionWrapper( + 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, + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(width: 8), + ), + const SliverToBoxAdapter( + child: SizedBox(width: 12), + ), + ], + ), + ), + ), + ); + } +} 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 41a9ca0..30813a9 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'; @@ -18,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, @@ -35,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, ); } @@ -160,8 +159,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 +174,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 +190,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/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index e255b6d..8cdb5af 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/confirm_delete_saved_view_dialog.dart b/lib/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart index 7aa31c9..d6aaddc 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/documents/view/widgets/selection/document_selection_sliver_app_bar.dart b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart index 76b4784..aa85dea 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,12 +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/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'; 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 +65,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 +104,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/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index 61b15a6..ec94f63 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), - ), - ), - 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, + 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), ), ), - ), - ); - } - : null, + 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, + ), + ), + ), + ); + } + : null, + ), ); }, ); diff --git a/lib/features/edit_label/view/edit_label_page.dart b/lib/features/edit_label/view/edit_label_page.dart index 7ad57e4..c73cef2 100644 --- a/lib/features/edit_label/view/edit_label_page.dart +++ b/lib/features/edit_label/view/edit_label_page.dart @@ -2,14 +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 { @@ -55,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, @@ -68,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), - ), - ], - ), - 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), + 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), + ), + additionalFields: additionalFields, ), - additionalFields: additionalFields, ), ); } @@ -119,11 +128,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/impl/add_storage_path_page.dart b/lib/features/edit_label/view/impl/add_storage_path_page.dart index 3e2d311..b033a72 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 3a13501..88310b4 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: [ @@ -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_correspondent_page.dart b/lib/features/edit_label/view/impl/edit_correspondent_page.dart index 1dce099..c358cd8 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 afd2f7f..824e0e7 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 3a56b55..91d512c 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 fbd62af..c9af9bd 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, @@ -37,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 0a26600..0ffd4c9 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -1,13 +1,12 @@ 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 { @@ -34,6 +33,7 @@ class LabelForm extends StatefulWidget { final List additionalFields; final bool autofocusNameField; + final GlobalKey? formKey; const LabelForm({ Key? key, @@ -42,6 +42,7 @@ class LabelForm extends StatefulWidget { this.additionalFields = const [], required this.submitButtonConfig, required this.autofocusNameField, + this.formKey, }) : super(key: key); @override @@ -49,7 +50,7 @@ class LabelForm extends StatefulWidget { } class _LabelFormState extends State> { - final _formKey = GlobalKey(); + late final GlobalKey _formKey; late bool _enableMatchFormField; @@ -58,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 && @@ -68,11 +70,12 @@ class _LabelFormState extends State> { Widget build(BuildContext context) { List selectableMatchingAlgorithmValues = getSelectableMatchingAlgorithmValues( - context.watch().hasMultiUserSupport, + context.watch().hasMultiUserSupport, ); return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: FloatingActionButton.extended( + heroTag: "fab_label_form", icon: widget.submitButtonConfig.icon, label: widget.submitButtonConfig.label, onPressed: _onSubmit, @@ -134,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(), @@ -167,7 +176,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_page.dart b/lib/features/home/view/home_page.dart deleted file mode 100644 index ae05192..0000000 --- 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 7b3b082..0000000 --- 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 0000000..40bc2f0 --- /dev/null +++ b/lib/features/home/view/home_shell_widget.dart @@ -0,0 +1,217 @@ +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/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'; +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/model/pending_tasks_notifier.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; + final apiVersion = ApiVersion(paperlessApiVersion); + return ValueListenableBuilder( + valueListenable: + 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), + 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( + lazy: false, + create: (context) => DocumentsCubit( + context.read(), + context.read(), + context.read(), + Hive.box( + HiveBoxes.localUserAppState) + .get(currentUserId)!, + context.read(), + )..initialize(), + ), + Provider( + create: (context) => + DocumentScannerCubit(context.read()) + ..initialize(), + ), + Provider( + create: (context) { + final inboxCubit = InboxCubit( + context.read(), + context.read(), + context.read(), + context.read(), + context.read(), + ); + if (currentLocalUser.paperlessUser.canViewInbox) { + inboxCubit.initialize(); + } + return inboxCubit; + }, + ), + Provider( + create: (context) => SavedViewCubit( + context.read(), + ), + ), + Provider( + create: (context) => LabelCubit( + context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => PendingTasksNotifier( + 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 a7cabd1..1a70fcd 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/route_description.dart b/lib/features/home/view/route_description.dart deleted file mode 100644 index 8440e03..0000000 --- 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 new file mode 100644 index 0000000..b9b2ee4 --- /dev/null +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -0,0 +1,151 @@ +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'; + +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 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.home_outlined), + selectedIcon: Icon( + Icons.home, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.home, + ), + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.description_outlined), + selectedIcon: Icon( + Icons.description, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.documents, + ), + disableWhen: !widget.authenticatedUser.canViewDocuments, + ), + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.document_scanner_outlined), + selectedIcon: Icon( + Icons.document_scanner, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.scanner, + ), + disableWhen: !widget.authenticatedUser.canCreateDocuments, + ), + _toggleDestination( + NavigationDestination( + icon: const Icon(Icons.sell_outlined), + selectedIcon: Icon( + Icons.sell, + color: theme.colorScheme.primary, + ), + label: S.of(context)!.labels, + ), + 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, + ), + 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/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart deleted file mode 100644 index f3885cb..0000000 --- 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 3356fd5..fa4465b 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'; @@ -19,6 +20,9 @@ class InboxCubit extends HydratedCubit final PaperlessDocumentsApi _documentsApi; + @override + final ConnectivityStatusService connectivityStatusService; + @override final DocumentChangedNotifier notifier; @@ -32,21 +36,35 @@ class InboxCubit extends HydratedCubit this._statsApi, this._labelRepository, this.notifier, - ) : super(InboxState( - labels: _labelRepository.state, - )) { + this.connectivityStatusService, + ) : 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)); + } } }, ); @@ -58,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)); } /// @@ -82,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!), ); @@ -110,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!), ); @@ -131,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()), @@ -151,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 32b7934..b4efa5e 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 { @@ -33,42 +35,99 @@ 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(); + context.read().reloadInbox(); + 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) { final canEditDocument = - LocalUserAccount.current.paperlessUser.canEditDocuments; + 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( - label: Text(S.of(context)!.allSeen), - icon: 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, 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) { @@ -213,6 +272,16 @@ class _InboxPageState extends State } Future _onItemDismissed(DocumentModel doc) async { + if (!context.read().paperlessUser.canEditDocuments) { + 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 c0da605..70fa034 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -3,7 +3,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/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -15,6 +14,8 @@ 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 { const InboxItemPlaceholder({super.key}); @@ -150,11 +151,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, @@ -227,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), @@ -238,8 +240,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/cubit/label_cubit.dart b/lib/features/labels/cubit/label_cubit.dart index 2edc2ac..c8d6d5f 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/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 328b6c9..0000000 --- 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 1a2b51e..4f6c74a 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,10 +69,12 @@ 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, child: const Icon(Icons.add), ) @@ -191,7 +194,7 @@ class _FullscreenTagsFormState extends State { final createdTag = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => AddTagPage( - initialValue: _textEditingController.text, + initialName: _textEditingController.text, ), ), ); @@ -237,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/tags/view/widgets/tags_form_field.dart b/lib/features/labels/tags/view/widgets/tags_form_field.dart index 1339f70..7ec3096 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 71bd9a4..213299e 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -7,23 +7,14 @@ 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/helpers/connectivity_aware_action_wrapper.dart'; +import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; class LabelsPage extends StatefulWidget { const LabelsPage({Key? key}) : super(key: key); @@ -40,6 +31,7 @@ class _LabelsPageState extends State SliverOverlapAbsorberHandle(); late final TabController _tabController; + int _currentIndex = 0; int _calculateTabCount(UserModel user) => [ @@ -52,12 +44,18 @@ 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)); } + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -67,22 +65,39 @@ class _LabelsPageState extends State final currentUserId = Hive.box(HiveBoxes.globalSettings) .getValue()! - .currentLoggedInUser; + .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( - onPressed: [ - if (user.canViewCorrespondents) _openAddCorrespondentPage, - if (user.canViewDocumentTypes) _openAddDocumentTypePage, - if (user.canViewTags) _openAddTagPage, - if (user.canViewStoragePaths) _openAddStoragePathPage, - ][_currentIndex], - child: const Icon(Icons.add), + 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], + ), ), body: NestedScrollView( floatHeaderSlivers: true, @@ -213,144 +228,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 +249,124 @@ 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(LabelType.correspondent).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(LabelType.documentType).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(LabelType.tag).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(LabelType.storagePath).push(context), + ), + ], + ); + }, ); } } diff --git a/lib/features/labels/view/widgets/fullscreen_label_form.dart b/lib/features/labels/view/widgets/fullscreen_label_form.dart index 8d827b6..c4c649e 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 de59604..de7e5f5 100644 --- a/lib/features/labels/view/widgets/label_item.dart +++ b/lib/features/labels/view/widgets/label_item.dart @@ -1,8 +1,9 @@ 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'; class LabelItem extends StatelessWidget { final T label; @@ -36,14 +37,14 @@ 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)), onPressed: canOpen ? () { final filter = filterBuilder(label); - pushLinkedDocumentsView(context, filter: filter); + LinkedDocumentsRoute(filter).push(context); } : null, ); diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index d99533e..9d6ce8c 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) { @@ -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/labels/view/widgets/label_text.dart b/lib/features/labels/view/widgets/label_text.dart index bf15b42..a1b3e1f 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 new file mode 100644 index 0000000..0f5d18f --- /dev/null +++ b/lib/features/landing/view/landing_page.dart @@ -0,0 +1,200 @@ +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/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/features/saved_view/cubit/saved_view_cubit.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/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/inbox_route.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) { + final currentUser = context.watch().paperlessUser; + 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: [ + SliverToBoxAdapter( + child: Text( + S.of(context)!.welcomeUser( + currentUser.fullName ?? currentUser.username), + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith(fontSize: 28), + ).padded(24), + ), + SliverToBoxAdapter(child: _buildStatisticsCard(context)), + if (currentUser.canViewSavedViews) ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 0, 8), + sliver: SliverToBoxAdapter( + 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, + ), + ], + ), + ), + ), + 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(S.of(context)!.noSavedViewOnHomepageHint) + .padded(), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: Text(S.of(context)!.newView), + ) + ], + ).paddedOnly(left: 16), + ); + } + return SliverList.builder( + itemBuilder: (context, index) { + return SavedViewPreview( + savedView: dashboardViews.elementAt(index), + expanded: index == 0, + ); + }, + itemCount: dashboardViews.length, + ); + }, + orElse: () => const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildStatisticsCard(BuildContext context) { + final currentUser = context.read().paperlessUser; + 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( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text(S.of(context)!.documentsInInbox), + onTap: currentUser.canViewInbox + ? () => InboxRoute().go(context) + : null, + trailing: Text( + stats.documentsInInbox.toString(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + Card( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text(S.of(context)!.totalDocuments), + onTap: currentUser.canViewDocuments + ? () { + DocumentsRoute().go(context); + } + : null, + trailing: Text( + stats.documentsTotal.toString(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + Card( + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + shape: Theme.of(context).cardTheme.shape, + titleTextStyle: Theme.of(context).textTheme.labelLarge, + title: Text(S.of(context)!.totalCharacters), + trailing: Text( + stats.totalChars.toString(), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + 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 0000000..fda1870 --- /dev/null +++ b/lib/features/landing/view/widgets/expansion_card.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class ExpansionCard extends StatelessWidget { + final Widget title; + final Widget content; + + final bool initiallyExpanded; + + const ExpansionCard({ + super.key, + required this.title, + required this.content, + this.initiallyExpanded = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + margin: const 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( + 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/landing/view/widgets/mime_types_pie_chart.dart b/lib/features/landing/view/widgets/mime_types_pie_chart.dart new file mode 100644 index 0000000..97320ba --- /dev/null +++ b/lib/features/landing/view/widgets/mime_types_pie_chart.dart @@ -0,0 +1,157 @@ +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.spaceAround, + 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 ? 18.0 : 16.0; + final radius = isTouched ? 60.0 : 50.0; + final percentage = type.count / widget.statistics.documentsTotal * 100; + yield PieChartSectionData( + color: colorShades[i % colorShades.length], + value: type.count.toDouble(), + title: percentage.toStringAsFixed(1) + "%", + radius: radius, + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ); + } + } +} + +extension AllShades on MaterialColor { + List get values => [ + shade200, + shade600, + shade300, + shade100, + shade800, + shade400, + shade900, + shade500, + shade700, + ]; +} diff --git a/lib/features/linked_documents/cubit/linked_documents_cubit.dart b/lib/features/linked_documents/cubit/linked_documents_cubit.dart index 39e3c0d..c7fbeba 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/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index 889da31..5a7fdba 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,12 +1,12 @@ 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/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'; 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 +51,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 fc11adf..3971acb 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,6 +1,6 @@ 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'; @@ -12,24 +12,36 @@ 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'; +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/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -part 'authentication_cubit.freezed.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, - ) : super(const AuthenticationState.unauthenticated()); + this._connectivityService, + this._notificationService, + ) : super(const UnauthenticatedState()); Future login({ required LoginFormCredentials credentials, @@ -37,33 +49,54 @@ class AuthenticationCubit extends Cubit { ClientCertificate? clientCertificate, }) async { assert(credentials.username != null && credentials.password != null); + if (state is AuthenticatingState) { + // Cancel duplicate login requests + return; + } + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; _debugPrintMessage( "login", "Trying to login $localUserId...", ); - await _addUser( - localUserId, - serverUrl, - credentials, - clientCertificate, - _sessionManager, - ); - - final apiVersion = await _getApiVersion(_sessionManager.client); + try { + await _addUser( + localUserId, + serverUrl, + credentials, + clientCertificate, + _sessionManager, + onFetchUserInformation: () async { + emit(const AuthenticatingState( + AuthenticatingStage.fetchingUserInformation)); + }, + onPerformLogin: () async { + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); + }, + onPersistLocalUserData: () async { + emit(const AuthenticatingState( + AuthenticatingStage.persistingLocalUserData)); + }, + ); + } catch (e) { + emit( + AuthenticationErrorState( + serverUrl: serverUrl, + username: credentials.username!, + password: credentials.password!, + clientCertificate: clientCertificate, + ), + ); + rethrow; + } // 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, - ), - ); + emit(AuthenticatedState(localUserId: localUserId)); _debugPrintMessage( "login", "User successfully logged in.", @@ -72,14 +105,24 @@ class AuthenticationCubit extends Cubit { /// Switches to another account if it exists. Future switchAccount(String localUserId) async { - emit(const AuthenticationState.switchingAccounts()); + emit(const SwitchingAccountsState()); + _debugPrintMessage( + "switchAccount", + "Trying to switch to user $localUserId...", + ); + final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - if (globalSettings.currentLoggedInUser == 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."); @@ -92,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)) { @@ -112,7 +163,7 @@ class AuthenticationCubit extends Cubit { baseUrl: account.serverUrl, ); - globalSettings.currentLoggedInUser = localUserId; + globalSettings.loggedInUserId = localUserId; await globalSettings.save(); final apiVersion = await _getApiVersion(_sessionManager.client); @@ -124,10 +175,7 @@ class AuthenticationCubit extends Cubit { apiVersion, ); - emit(AuthenticationState.authenticated( - localUserId: localUserId, - apiVersion: apiVersion, - )); + emit(AuthenticatedState(localUserId: localUserId)); }); } @@ -136,27 +184,39 @@ 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 { - 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); await withEncryptedBox( @@ -166,27 +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.currentLoggedInUser; - 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( + 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...", @@ -196,10 +262,13 @@ 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()); + emit(VerifyIdentityState(userId: restoreSessionForUser)); _debugPrintMessage( "restoreSessionState", "User could not be authenticated.", @@ -223,7 +292,7 @@ class AuthenticationCubit extends Cubit { final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { - return box.get(globalSettings.currentLoggedInUser!); + return box.get(restoreSessionForUser); }); if (authentication == null) { @@ -233,16 +302,19 @@ class AuthenticationCubit extends Cubit { ); throw Exception( "User should be authenticated but no authentication information was found.", - ); //TODO: INTL + ); } + _debugPrintMessage( "restoreSessionState", "Authentication credentials successfully retrieved.", ); + _debugPrintMessage( "restoreSessionState", "Updating current session state...", ); + _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, @@ -252,37 +324,62 @@ class AuthenticationCubit extends Cubit { "restoreSessionState", "Current session state successfully updated.", ); + final isPaperlessServerReachable = + await _connectivityService.isPaperlessServerReachable( + localUserAccount.serverUrl, + authentication.clientCertificate, + ) == + ReachabilityStatus.reachable; + if (isPaperlessServerReachable) { + _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 (server could not be reached).", + ); + } + globalSettings.loggedInUserId = restoreSessionForUser; + await globalSettings.save(); + emit(AuthenticatedState(localUserId: restoreSessionForUser)); - final apiVersion = await _getApiVersion(_sessionManager.client); - await _updateRemoteUser( - _sessionManager, - localUserAccount, - apiVersion, - ); - emit( - AuthenticationState.authenticated( - apiVersion: apiVersion, - localUserId: localUserId, - ), - ); _debugPrintMessage( "restoreSessionState", "Session was successfully restored.", ); } - Future logout() async { + Future logout([bool removeAccount = false]) async { + emit(const LoggingOutState()); _debugPrintMessage( "logout", "Trying to log out current user...", ); await _resetExternalState(); - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.currentLoggedInUser = null; + 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) { + await this.removeAccount(userId); + } + globalSettings.loggedInUserId = null; await globalSettings.save(); - emit(const AuthenticationState.unauthenticated()); _debugPrintMessage( "logout", "User successfully logged out.", @@ -290,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( @@ -307,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..."); @@ -324,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!, @@ -350,8 +444,9 @@ class AuthenticationCubit extends Cubit { "_addUser", "An error occurred! The user $localUserId already exists.", ); - throw Exception("User already exists!"); + throw InfoMessageException(code: ErrorCode.userAlreadyExists); } + await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); _debugPrintMessage( "_addUser", @@ -381,6 +476,7 @@ class AuthenticationCubit extends Cubit { "_addUser", "Persisting local user account...", ); + await onPersistLocalUserData?.call(); // Create user account await userAccountBox.put( localUserId, @@ -389,6 +485,7 @@ class AuthenticationCubit extends Cubit { settings: LocalUserSettings(), serverUrl: serverUrl, paperlessUser: serverUser, + apiVersion: apiVersion, ), ); _debugPrintMessage( @@ -434,19 +531,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 (_) { + 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 fbe0790..1ad5fab 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -1,13 +1,82 @@ part of 'authentication_cubit.dart'; -@freezed -class AuthenticationState with _$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; +sealed class AuthenticationState { + const AuthenticationState(); + + bool get isAuthenticated => + switch (this) { AuthenticatedState() => true, _ => false }; +} + +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 VerifyIdentityState extends AuthenticationState { + final String userId; + const VerifyIdentityState({required this.userId}); +} + +class AuthenticatingState extends AuthenticationState with EquatableMixin { + final AuthenticatingStage currentStage; + const AuthenticatingState(this.currentStage); + + @override + List get props => [currentStage]; +} + +class LoggingOutState extends AuthenticationState { + const LoggingOutState(); +} + +class AuthenticatedState extends AuthenticationState with EquatableMixin { + final String localUserId; + + const AuthenticatedState({required this.localUserId}); + + @override + List get props => [localUserId]; +} + +class SwitchingAccountsState extends AuthenticationState { + const SwitchingAccountsState(); +} + +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 a2bd806..0000000 --- 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 8c920ba..00c24c8 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 afb9ddb..b168d15 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 new file mode 100644 index 0000000..e7ab5b6 --- /dev/null +++ b/lib/features/login/view/add_account_page.dart @@ -0,0 +1,260 @@ +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:paperless_api/paperless_api.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/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/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +class AddAccountPage extends StatefulWidget { + final FutureOr Function( + BuildContext context, + String username, + String password, + String serverUrl, + ClientCertificate? clientCertificate, + ) onSubmit; + + 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.titleText, + this.showLocalAccounts = false, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + this.bottomLeftButton, + }) : super(key: key); + + @override + State createState() => _AddAccountPageState(); +} + +class _AddAccountPageState extends State { + final _formKey = GlobalKey(); + bool _isCheckingConnection = false; + ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; + + bool _isFormSubmitted = false; + @override + Widget build(BuildContext context) { + 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 _onSubmit() async { + FocusScope.of(context).unfocus(); + setState(() { + _isFormSubmitted = true; + }); + 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); + } on InfoMessageException catch (error) { + 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 578585c..ffc2998 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -1,161 +1,104 @@ -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/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'; 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/top_level/login_route.dart'; -import 'widgets/login_pages/server_login_page.dart'; -import 'widgets/never_scrollable_scroll_behavior.dart'; +class LoginPage extends StatelessWidget { + final String? initialServerUrl; + final String? initialUsername; + final String? initialPassword; + final ClientCertificate? initialClientCertificate; -class LoginPage extends StatefulWidget { - final FutureOr Function( + const LoginPage({ + super.key, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + }); + + @override + Widget build(BuildContext context) { + return AddAccountPage( + 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, + ); + } + + 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, - ); - } - final credentials = - form[UserCredentialsFormField.fkCredentials] as LoginFormCredentials; - try { - await widget.onSubmit( + serverUrl: serverUrl, + clientCertificate: clientCertificate, + ); + // Show onboarding after first login! + final globalSettings = + Hive.box(HiveBoxes.globalSettings).getValue()!; + if (globalSettings.showOnboarding) { + Navigator.push( 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); + MaterialPageRoute( + builder: (context) => const ApplicationIntroSlideshow(), + fullscreenDialog: true, + ), + ).then((value) { + globalSettings.showOnboarding = false; + globalSettings.save(); + }); } + // 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, + exception.validationMessages.values.first, + 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/login/view/login_to_existing_account_page.dart b/lib/features/login/view/login_to_existing_account_page.dart new file mode 100644 index 0000000..be2a11a --- /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 0000000..cd482da --- /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 3936de8..b0c3753 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 fe09006..cff93d8 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 deb24c1..397d563 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 29a3c96..0000000 --- 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 df64d69..0000000 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ /dev/null @@ -1,79 +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: () async { - setState(() => _isLoginLoading = true); - await widget.onSubmit(); - setState(() => _isLoginLoading = false); - }, - 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 0000000..3976418 --- /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 bfdc0fd..0000000 --- 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 9f69782..bfad7fc 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,8 @@ class LocalNotificationService { required String filePath, required bool finished, required String locale, + required String userId, + double? progress, }) async { final tr = await S.delegate.load(Locale(locale)); @@ -65,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, @@ -88,6 +94,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,25 +134,20 @@ class LocalNotificationService { ), iOS: DarwinNotificationDetails( attachments: [ - DarwinNotificationAttachment( - filePath, - ), + DarwinNotificationAttachment(filePath), ], ), ), payload: jsonEncode( - OpenDownloadedDocumentPayload( - filePath: filePath, - ).toJson(), + OpenDownloadedDocumentPayload(filePath: filePath).toJson(), ), - ); //TODO: INTL + ); } - //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; @@ -158,7 +168,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: @@ -172,7 +182,7 @@ class LocalNotificationService { default: break; } - return _plugin.show( + await _plugin.show( id, title, body, @@ -205,6 +215,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( @@ -273,6 +290,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/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 4d7ed16..3c9cd67 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,7 @@ 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 'paged_documents_state.dart'; @@ -11,13 +12,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)); @@ -46,9 +50,40 @@ mixin DocumentPagingBlocMixin /// Use [loadMore] to load more data. Future updateFilter({ final DocumentFilter filter = const DocumentFilter(), + bool emitLoading = true, }) 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(); + if (emitLoading) { + 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)); + if (emitLoading) { + emit(state.copyWithPaged(isLoading: true)); + } final result = await api.findAll(filter.copyWith(page: 1)); emit( @@ -68,7 +103,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)); @@ -115,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); @@ -182,6 +217,7 @@ mixin DocumentPagingBlocMixin } } + @override Future close() { notifier.removeListener(this); diff --git a/lib/features/saved_view/cubit/saved_view_cubit.dart b/lib/features/saved_view/cubit/saved_view_cubit.dart index 9215c2c..a2c255b 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; @@ -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 e0b6e88..78ca390 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,24 +1,20 @@ 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/extensions/flutter_extensions.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 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 @@ -26,12 +22,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( @@ -39,6 +30,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), @@ -54,7 +46,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; @@ -65,57 +57,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(), - 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, - ), - ), ], ), ), ); } - void _onCreate(BuildContext context) { + void _onCreate(BuildContext context) async { if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { - Navigator.pop( - context, - 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 0000000..3ad1d06 --- /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/view/saved_view_list.dart b/lib/features/saved_view/view/saved_view_list.dart deleted file mode 100644 index bc6b935..0000000 --- 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 2468115..0000000 --- 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/cubit/saved_view_details_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart index 4109f7b..33af9fa 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,8 +29,10 @@ class SavedViewDetailsCubit extends Cubit this.api, this.notifier, this._labelRepository, - this._userState, { + this._userState, + this.connectivityStatusService, { required this.savedView, + int initialCount = 25, }) : super( SavedViewDetailsState( correspondents: _labelRepository.state.correspondents, @@ -56,7 +60,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 0000000..2bd8fcd --- /dev/null +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -0,0 +1,37 @@ +import 'package:bloc/bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; + +part 'saved_view_preview_state.dart'; + +class SavedViewPreviewCubit extends Cubit { + final PaperlessDocumentsApi _api; + final SavedView view; + final ConnectivityStatusService _connectivityStatusService; + SavedViewPreviewCubit( + this._api, + this._connectivityStatusService, { + required this.view, + }) : super(const InitialSavedViewPreviewState()); + + Future initialize() async { + final isConnected = + await _connectivityStatusService.isConnectedToInternet(); + if (!isConnected) { + emit(const OfflineSavedViewPreviewState()); + return; + } + emit(const LoadingSavedViewPreviewState()); + try { + final documents = await _api.findAll( + view.toDocumentFilter().copyWith( + page: 1, + pageSize: 5, + ), + ); + emit(LoadedSavedViewPreviewState(documents: documents.results)); + } catch (e) { + 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 new file mode 100644 index 0000000..a0de113 --- /dev/null +++ b/lib/features/saved_view_details/cubit/saved_view_preview_state.dart @@ -0,0 +1,29 @@ +part of 'saved_view_preview_cubit.dart'; + +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_details_page.dart b/lib/features/saved_view_details/view/saved_view_details_page.dart deleted file mode 100644 index d82bd21..0000000 --- a/lib/features/saved_view_details/view/saved_view_details_page.dart +++ /dev/null @@ -1,101 +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/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'; - -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.read(); - 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); - Navigator.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) { - pushDocumentDetailsRoute( - context, - document: document, - isLabelClickable: false, - ); - }, - 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 new file mode 100644 index 0000000..2ebb459 --- /dev/null +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -0,0 +1,96 @@ +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'; + +class SavedViewPreview extends StatelessWidget { + final SavedView savedView; + final bool expanded; + const SavedViewPreview({ + super.key, + required this.savedView, + required this.expanded, + }); + + @override + Widget build(BuildContext context) { + return Provider( + create: (context) => SavedViewPreviewCubit( + context.read(), + context.read(), + view: savedView, + )..initialize(), + builder: (context, child) { + return ExpansionCard( + initiallyExpanded: expanded, + title: Text(savedView.name), + content: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + BlocBuilder( + builder: (context, state) { + 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( + 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/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index 3592144..1669716 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/login_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,20 +17,18 @@ 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.currentLoggedInUser == 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.currentLoggedInUser) + .whereNot((element) => element == globalSettings.loggedInUserId) .toList(); return SimpleDialog( insetPadding: const EdgeInsets.all(24), @@ -54,7 +49,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,13 +66,10 @@ class ManageAccountsPage extends StatelessWidget { ], onSelected: (value) async { if (value == 0) { - final currentUser = - globalSettings.currentLoggedInUser!; - await context.read().logout(); Navigator.of(context).pop(); await context .read() - .removeAccount(currentUser); + .logout(true); } }, ), @@ -117,7 +109,7 @@ class ManageAccountsPage extends StatelessWidget { // Switch _onSwitchAccount( context, - globalSettings.currentLoggedInUser!, + globalSettings.loggedInUserId!, otherAccounts[index], ); } else if (value == 1) { @@ -135,14 +127,15 @@ 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) - 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), + // ), ], ); }, @@ -152,43 +145,43 @@ class ManageAccountsPage extends StatelessWidget { } Future _onAddAccount(BuildContext context, String currentUser) async { - final userId = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LoginPage( - 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 8bfd21d..0000000 --- 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/settings_page.dart b/lib/features/settings/view/settings_page.dart index 2740172..5be033c 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(), ], @@ -100,14 +102,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/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart index 18c35cb..5a6f227 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -52,10 +52,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/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index 6d93c08..8787acb 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -15,11 +15,13 @@ 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), + 'ru': LanguageOption('Русский', true), }; @override @@ -33,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 new file mode 100644 index 0000000..473e488 --- /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'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; + +class SkipDocumentPreprationOnShareSetting extends StatelessWidget { + const SkipDocumentPreprationOnShareSetting({super.key}); + + @override + Widget build(BuildContext context) { + return GlobalSettingsBuilder( + builder: (context, settings) { + return SwitchListTile( + title: Text(S.of(context)!.skipEditingReceivedFiles), + subtitle: Text(S.of(context)!.uploadWithoutPromptingUploadForm), + value: settings.skipDocumentPreprarationOnUpload, + onChanged: (value) { + settings.skipDocumentPreprarationOnUpload = value; + 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 50c2700..43d4c7c 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/features/settings/view/widgets/user_settings_builder.dart b/lib/features/settings/view/widgets/user_settings_builder.dart index 6512efe..2201a58 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/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart new file mode 100644 index 0000000..19317d4 --- /dev/null +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; + +part 'receive_share_state.dart'; + +class ConsumptionChangeNotifier extends ChangeNotifier { + List pendingFiles = []; + + 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(); + } + + /// 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 0000000..c17ce2b --- /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/share_intent_queue.dart b/lib/features/sharing/share_intent_queue.dart deleted file mode 100644 index e551283..0000000 --- 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 0000000..7b437b4 --- /dev/null +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.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/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'; + +class ConsumptionQueueView extends StatelessWidget { + const ConsumptionQueueView({super.key}); + + @override + Widget build(BuildContext context) { + final currentUser = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text("Pending Files"), //TODO: INTL + ), + body: Consumer( + builder: (context, value, child) { + if (value.pendingFiles.isEmpty) { + return Center( + child: Text("There are no pending files."), //TODO: INTL + ); + } + return ListView.builder( + itemBuilder: (context, index) { + final file = value.pendingFiles.elementAt(index); + final filename = p.basename(file.path); + return ListTile( + 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( + borderRadius: BorderRadius.circular(8), + child: FileThumbnail( + file: file, + fit: BoxFit.cover, + width: 75, + ), + ), + ), + ); + }, + 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 0000000..ae81620 --- /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:paperless_mobile/features/sharing/view/widgets/file_thumbnail.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 ClipRRect( + borderRadius: BorderRadius.circular(12), + child: FileThumbnail( + bytes: snapshot.data!, + width: 150, + height: 100, + fit: BoxFit.cover, + ), + ); + }, + ), + 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/dialog/pending_files_info_dialog.dart b/lib/features/sharing/view/dialog/pending_files_info_dialog.dart new file mode 100644 index 0000000..d4d087a --- /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/event_listener_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart new file mode 100644 index 0000000..33a4426 --- /dev/null +++ b/lib/features/sharing/view/widgets/event_listener_shell.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +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/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:path/path.dart' as p; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +class EventListenerShell extends StatefulWidget { + final Widget child; + const EventListenerShell({super.key, required this.child}); + + @override + State createState() => _EventListenerShellState(); +} + +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; + // final pendingFiles = notifier.pendingFiles; + // if (pendingFiles.isEmpty) { + // return; + // } + + // final shouldProcess = await showDialog( + // context: context, + // builder: (context) => + // PendingFilesInfoDialog(pendingFiles: pendingFiles), + // ) ?? + // false; + // if (shouldProcess) { + // final userId = context.read().id; + // await consumeLocalFiles( + // context, + // files: pendingFiles, + // userId: userId, + // ); + // } + // }); + } + + 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; + for (var task in taskNotifier.value.values) { + context + .read() + .notifyTaskChanged(task, userId: userId); + } + } + + 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, + ); + consumeLocalFiles( + context, + files: files, + userId: userId, + exitAppAfterConsumed: true, + ); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +Future consumeLocalFile( + BuildContext context, { + required File file, + 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 bytes = file.readAsBytes(); + final shouldDirectlyUpload = + Hive.globalSettingsBox.getValue()!.skipDocumentPreprarationOnUpload; + if (shouldDirectlyUpload) { + 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( + $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); + } + } + } +} + +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/sharing/view/widgets/file_thumbnail.dart b/lib/features/sharing/view/widgets/file_thumbnail.dart new file mode 100644 index 0000000..8abfa20 --- /dev/null +++ b/lib/features/sharing/view/widgets/file_thumbnail.dart @@ -0,0 +1,103 @@ +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; + 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 + Widget build(BuildContext context) { + return switch (mimeType) { + "application/pdf" => SizedBox( + width: widget.width, + height: widget.height, + child: Center( + child: FutureBuilder( + future: _fileBytes, + 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/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart index 6e24b98..4ec4653 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/features/similar_documents/view/similar_documents_view.dart b/lib/features/similar_documents/view/similar_documents_view.dart index 35564c4..3219fe7 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/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart deleted file mode 100644 index 66d0a87..0000000 --- a/lib/features/tasks/cubit/task_status_cubit.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:paperless_api/paperless_api.dart'; -part 'task_status_state.dart'; - -class TaskStatusCubit extends Cubit { - final PaperlessTasksApi _api; - TaskStatusCubit(this._api) : super(const TaskStatusState()); - - void listenToTaskChanges(String taskId) { - _api - .listenForTaskChanges(taskId) - .forEach( - (element) => emit( - TaskStatusState( - isListening: true, - task: element, - ), - ), - ) - .whenComplete(() => emit(state.copyWith(isListening: false))); - } - - Future acknowledgeCurrentTask() async { - if (state.task == null) { - return; - } - final task = await _api.acknowledgeTask(state.task!); - emit( - state.copyWith( - task: task, - isListening: false, - ), - ); - } -} 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 163d3db..0000000 --- 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/features/tasks/model/pending_tasks_notifier.dart b/lib/features/tasks/model/pending_tasks_notifier.dart new file mode 100644 index 0000000..f99837f --- /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 { + for (var sub in _subscriptions.values) { + sub.cancel(); + } + _subscriptions.clear(); + } + } + + 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/helpers/connectivity_aware_action_wrapper.dart b/lib/helpers/connectivity_aware_action_wrapper.dart new file mode 100644 index 0000000..f69e922 --- /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/file_helpers.dart b/lib/helpers/file_helpers.dart deleted file mode 100644 index 6c4577f..0000000 --- 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 05e8de7..0000000 --- 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/helpers/message_helpers.dart b/lib/helpers/message_helpers.dart index 0ac415a..39f532b 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 { @@ -25,6 +26,7 @@ void showSnackBar( ..hideCurrentSnackBar() ..showSnackBar( SnackBar( + behavior: SnackBarBehavior.floating, content: (details != null) ? RichText( maxLines: 5, @@ -107,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 d38de26..498a448 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": {}, @@ -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,144 @@ "@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": "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": "Editar Vista", + "@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" + }, + "noDocumentsFound": "Sense Documents trobats.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "No es pot esborrar corresponsal, torna a provar.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "No es pot esborrar document, prova de nou.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "No es pot esborrar etiqueta, prova de nou.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "No es pot esborrar ruta emmagatzematge, prova de nou.", + "@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": "Descartar canvis?", + "@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": "Inici", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Benvingut {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": "Estadí­stiques", + "documentsInInbox": "Document safata", + "totalDocuments": "Total documents", + "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": "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", + "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 394defd..c631625 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": {}, @@ -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,141 @@ "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." + }, + "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" + }, + "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" + }, + "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", + "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 68744b4..5f947a6 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": {}, @@ -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,141 @@ "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." + }, + "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" + }, + "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" + }, + "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", + "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 b9620ca..a89ae96 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": {}, @@ -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,141 @@ "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." + }, + "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" + }, + "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" + }, + "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", + "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 new file mode 100644 index 0000000..068a86f --- /dev/null +++ b/lib/l10n/intl_es.arb @@ -0,0 +1,1000 @@ +{ + "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" + }, + "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" + }, + "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", + "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 403f810..da7ec87 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": {}, @@ -812,53 +812,189 @@ "@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." + }, + "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" + }, + "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" + }, + "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", + "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 db13285..151cf85 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": {}, @@ -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,141 @@ "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." + }, + "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" + }, + "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" + }, + "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", + "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 2abc032..96e417e 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": { @@ -21,844 +21,980 @@ "@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" }, - "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": "Delete view ", + "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 {bytes}", + "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": "У вас нет необходимых разрешений для выполнения этого действия.", + "@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": "Пожертвовать", + "@donate": { + "description": "Label of the in-app donate button" + }, + "donationDialogContent": "Спасибо, что решили поддержать это приложение! В соответствии с политикой платежей Google и Apple, ссылки, ведущие на пожертвования, не могут отображаться в приложении. Даже ссылки на страницу репозитория проекта, по-видимому, не разрешены в данном контексте. Поэтому, возможно, стоит обратить внимание на раздел \"Пожертвования\" в README проекта. Мы очень ценим вашу поддержку и поддерживаем развитие этого приложения. Спасибо!", + "@donationDialogContent": { + "description": "Text displayed in the donation dialog" + }, + "noDocumentsFound": "Документы не найдены.", + "@noDocumentsFound": { + "description": "Message shown when no documents were found." + }, + "couldNotDeleteCorrespondent": "Не удалось удалить корреспондента, попробуйте еще раз.", + "@couldNotDeleteCorrespondent": { + "description": "Message shown in snackbar when a correspondent could not be deleted." + }, + "couldNotDeleteDocumentType": "Не удалось удалить тип документа, попробуйте еще раз.", + "@couldNotDeleteDocumentType": { + "description": "Message shown when a document type could not be deleted" + }, + "couldNotDeleteTag": "Не удалось удалить тег, попробуйте еще раз.", + "@couldNotDeleteTag": { + "description": "Message shown when a tag could not be deleted" + }, + "couldNotDeleteStoragePath": "Не удалось удалить путь к хранилищу, попробуйте еще раз.", + "@couldNotDeleteStoragePath": { + "description": "Message shown when a storage path could not be deleted" + }, + "couldNotUpdateCorrespondent": "Не удалось обновить корреспондента, попробуйте еще раз.", + "@couldNotUpdateCorrespondent": { + "description": "Message shown when a correspondent could not be updated" + }, + "couldNotUpdateDocumentType": "Не удалось обновить тип документа, попробуйте еще раз.", + "@couldNotUpdateDocumentType": { + "description": "Message shown when a document type could not be updated" + }, + "couldNotUpdateTag": "Не удалось обновить тег, попробуйте еще раз.", + "@couldNotUpdateTag": { + "description": "Message shown when a tag could not be updated" + }, + "couldNotLoadServerInformation": "Не удалось загрузить информацию о сервере.", + "@couldNotLoadServerInformation": { + "description": "Message shown when the server information could not be loaded" + }, + "couldNotLoadStatistics": "Не удалось загрузить статистику сервера.", + "@couldNotLoadStatistics": { + "description": "Message shown when the server statistics could not be loaded" + }, + "couldNotLoadUISettings": "Не удалось загрузить настройки пользовательского интерфейса.", + "@couldNotLoadUISettings": { + "description": "Message shown when the UI settings could not be loaded" + }, + "couldNotLoadTasks": "Не удалось загрузить задания.", + "@couldNotLoadTasks": { + "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" + }, + "userNotFound": "Не удалось найти пользователя.", + "@userNotFound": { + "description": "Message shown when the specified user (e.g. by id) could not be found" + }, + "couldNotUpdateSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз.", + "@couldNotUpdateSavedView": { + "description": "Message shown when a saved view could not be updated" + }, + "couldNotUpdateStoragePath": "Не удалось обновить путь к хранилищу, попробуйте еще раз.", + "savedViewSuccessfullyUpdated": "Сохраненный вид успешно обновлен.", + "@savedViewSuccessfullyUpdated": { + "description": "Message shown when a saved view was successfully updated." + }, + "discardChanges": "Не сохранять изменения?", + "@discardChanges": { + "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." + }, + "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": "Создать из текущего фильтра", + "@createFromCurrentFilter": { + "description": "Tooltip of the \"New saved view\" button" + }, + "home": "Домашняя страница", + "@home": { + "description": "Label of the \"Home\" route" + }, + "welcomeUser": "Добро пожаловать, {name}!", + "@welcomeUser": { + "description": "Top message shown on the home page" + }, + "noSavedViewOnHomepageHint": "Настройте сохраненный вид для отображения на вашей домашней страница и он будет отображаться здесь.", + "@noSavedViewOnHomepageHint": { + "description": "Message shown when there is no saved view to display on the home page." + }, + "statistics": "Статистика", + "documentsInInbox": "Документы во входящих", + "totalDocuments": "Всего документов", + "totalCharacters": "Всего символов", + "showAll": "Показать все", + "@showAll": { + "description": "Button label shown on a saved view preview to open this view in the documents page" + }, + "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", + "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 183a5a1..0fb7f09 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": {}, @@ -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,141 @@ "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." + }, + "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" + }, + "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" + }, + "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", + "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 d86dd73..ef08823 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,12 +9,12 @@ 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'; 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'; @@ -30,19 +30,25 @@ 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/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/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/theme.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -76,17 +82,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 = @@ -95,18 +101,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(), @@ -138,13 +145,18 @@ void main() async { }); final apiFactory = PaperlessApiFactoryImpl(sessionManager); - + final authenticationCubit = AuthenticationCubit( + localAuthService, + apiFactory, + sessionManager, + connectivityStatusService, + localNotificationService, + ); runApp( MultiProvider( providers: [ ChangeNotifierProvider.value(value: sessionManager), Provider.value(value: localAuthService), - Provider.value(value: connectivity), Provider.value( value: connectivityStatusService), Provider.value( @@ -154,18 +166,14 @@ 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), ), ), ); }, (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, @@ -174,34 +182,161 @@ 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); +class GoRouterShell extends StatefulWidget { + final PaperlessApiFactory apiFactory; + const GoRouterShell({ + super.key, + required this.apiFactory, + }); @override - State createState() => - _PaperlessMobileEntrypointState(); + State createState() => _GoRouterShellState(); } -class _PaperlessMobileEntrypointState extends State { +class _GoRouterShellState extends State { + @override + void initState() { + super.initState(); + if (Platform.isAndroid) { + _setOptimalDisplayMode(); + } + initializeDateFormatting(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + context.read().restoreSession(); + FlutterNativeSplash.remove(); + }); + } + + /// Activates the highest supported refresh rate on the device. + 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); + } + + late final _router = GoRouter( + debugLogDiagnostics: kDebugMode, + initialLocation: "/login", + routes: [ + 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, + routes: [ + $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], + ), + ], + ), + ], + ), + ], + ), + ], + ); + @override Widget build(BuildContext context) { return GlobalSettingsBuilder( builder: (context, settings) { return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { - return MaterialApp( + return MaterialApp.router( + routerConfig: _router, debugShowCheckedModeBanner: true, title: "Paperless Mobile", theme: buildTheme( @@ -219,12 +354,7 @@ class _PaperlessMobileEntrypointState extends State { locale: Locale.fromSubtags( languageCode: settings.preferredLocaleSubtag, ), - localizationsDelegates: const [ - ...S.localizationsDelegates, - ], - home: AuthenticationWrapper( - paperlessProviderFactory: widget.paperlessProviderFactory, - ), + localizationsDelegates: S.localizationsDelegates, ); }, ); @@ -232,124 +362,3 @@ class _PaperlessMobileEntrypointState extends State { ); } } - -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: () => 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(), - ); - }, - ); - } - - 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); - } - } -} diff --git a/lib/routes/document_details_route.dart b/lib/routes/document_details_route.dart deleted file mode 100644 index a90001e..0000000 --- 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 0000000..c99c21c --- /dev/null +++ b/lib/routes/navigation_keys.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +final rootNavigatorKey = GlobalKey(); +final outerShellNavigatorKey = GlobalKey(); +final landingNavigatorKey = GlobalKey(); +final documentsNavigatorKey = GlobalKey(); +final scannerNavigatorKey = GlobalKey(); +final labelsNavigatorKey = GlobalKey(); +final inboxNavigatorKey = GlobalKey(); +final settingsNavigatorKey = GlobalKey(); diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart new file mode 100644 index 0000000..7f48599 --- /dev/null +++ b/lib/routes/routes.dart @@ -0,0 +1,29 @@ +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 switchingAccount = "switchingAccount"; + static const savedView = "savedView"; + static const createSavedView = "createSavedView"; + static const editSavedView = "editSavedView"; + 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"; + static const linkedDocuments = "linkedDocuments"; + static const bulkEditDocuments = "bulkEditDocuments"; + static const uploadQueue = "uploadQueue"; + 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 new file mode 100644 index 0000000..9134681 --- /dev/null +++ b/lib/routes/typed/branches/documents_route.dart @@ -0,0 +1,231 @@ +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/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'; +import 'package:paperless_mobile/theme.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, + ), + TypedGoRoute( + path: "bulk-edit", + name: R.bulkEditDocuments, + ), + ], +) +class DocumentsRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const DocumentsPage(); + } +} + +class DocumentDetailsRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; + + 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 = + outerShellNavigatorKey; + + final DocumentModel $extra; + + const EditDocumentRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + 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(), + ), + ); + } +} + +class DocumentPreviewRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; + + final DocumentModel $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/inbox_route.dart b/lib/routes/typed/branches/inbox_route.dart new file mode 100644 index 0000000..48f038f --- /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 0000000..fa81e77 --- /dev/null +++ b/lib/routes/typed/branches/labels_route.dart @@ -0,0 +1,111 @@ +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'; +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/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'; + +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, + ), + TypedGoRoute( + path: "linked-documents", + name: R.linkedDocuments, + ), + ], +) +class LabelsRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const LabelsPage(); + } +} + +class EditLabelRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; + + 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 = + outerShellNavigatorKey; + final LabelType $extra; + final String? name; + + CreateLabelRoute( + this.$extra, { + this.name, + }); + + @override + Widget build(BuildContext context, GoRouterState state) { + 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 = + outerShellNavigatorKey; + 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(), + context.read(), + ), + child: const LinkedDocumentsPage(), + ); + } +} diff --git a/lib/routes/typed/branches/landing_route.dart b/lib/routes/typed/branches/landing_route.dart new file mode 100644 index 0000000..1b501c6 --- /dev/null +++ b/lib/routes/typed/branches/landing_route.dart @@ -0,0 +1,25 @@ +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, +) +class LandingRoute extends GoRouteData { + const LandingRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return const LandingPage(); + } +} 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 0000000..0091170 --- /dev/null +++ b/lib/routes/typed/branches/saved_views_route.dart @@ -0,0 +1,45 @@ +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'; + +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(); +} + +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 { + final SavedView $extra; + const EditSavedViewRoute(this.$extra); + + @override + Widget build(BuildContext context, GoRouterState state) { + return EditSavedViewPage(savedView: $extra); + } +} diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart new file mode 100644 index 0000000..ec72171 --- /dev/null +++ b/lib/routes/typed/branches/scanner_route.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +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 = + outerShellNavigatorKey; + final FutureOr $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(), + context.read(), + ), + child: DocumentUploadPreparationPage( + title: title, + fileExtension: fileExtension, + filename: filename, + fileBytes: $extra, + ), + ); + } +} 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 0000000..77521e5 --- /dev/null +++ b/lib/routes/typed/branches/upload_queue_route.dart @@ -0,0 +1,21 @@ +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 = + outerShellNavigatorKey; + + @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 new file mode 100644 index 0000000..32b4f28 --- /dev/null +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.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/features/sharing/cubit/receive_share_cubit.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'; + +/// Key used to access + +//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 = outerShellNavigatorKey; + + const ProviderShellRoute(this.apiFactory); + + Widget build( + BuildContext context, + GoRouterState state, + Widget navigator, + ) { + final currentUserId = Hive.box(HiveBoxes.globalSettings) + .getValue()! + .loggedInUserId; + if (currentUserId == null) { + return const SizedBox.shrink(); + } + final authenticatedUser = + Hive.box(HiveBoxes.localUserAccount).get( + currentUserId, + )!; + + return HomeShellWidget( + localUserId: authenticatedUser.id, + paperlessApiVersion: authenticatedUser.apiVersion, + paperlessProviderFactory: apiFactory, + child: ChangeNotifierProvider( + create: (context) => ConsumptionChangeNotifier() + ..loadFromConsumptionDirectory(userId: currentUserId), + child: EventListenerShell(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 0000000..cd86589 --- /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/add_account_route.dart b/lib/routes/typed/top_level/add_account_route.dart new file mode 100644 index 0000000..6c4375e --- /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/logging_out_route.dart b/lib/routes/typed/top_level/logging_out_route.dart new file mode 100644 index 0000000..e78479e --- /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 LoggingOutRoute extends GoRouteData { + const LoggingOutRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return Scaffold( + body: Center( + child: Text("Logging out..."), + ), + ); + } +} 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 0000000..d8bf146 --- /dev/null +++ b/lib/routes/typed/top_level/login_route.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +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'; + +@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 { + 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 LoginPage( + initialServerUrl: serverUrl, + initialUsername: username, + initialPassword: password, + initialClientCertificate: $extra, + ); + } + + @override + FutureOr redirect(BuildContext context, GoRouterState state) { + if (context.read().state.isAuthenticated) { + return "/landing"; + } + 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 new file mode 100644 index 0000000..8a93e74 --- /dev/null +++ b/lib/routes/typed/top_level/settings_route.dart @@ -0,0 +1,28 @@ +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'; + +@TypedGoRoute( + path: "/settings", + name: R.settings, +) +class SettingsRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) { + 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 d1543ba..31d03a7 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; @@ -8,6 +9,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 +47,13 @@ ThemeData buildTheme({ colorScheme: colorScheme.harmonized(), useMaterial3: true, ).copyWith( + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: colorScheme.surface, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + ), + cardTheme: _defaultCardTheme, inputDecorationTheme: _defaultInputDecorationTheme, listTileTheme: _defaultListTileTheme, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -53,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/mock_server/lib/mock_server.dart b/packages/mock_server/lib/mock_server.dart index f82dc3d..84992cd 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 347c534..1dffc82 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/document_filter.dart b/packages/paperless_api/lib/src/models/document_filter.dart index ca2becd..19ceea3 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_form_validation_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart index 5599308..b300b54 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/models/exception/paperless_server_message_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_server_message_exception.dart index 51cc066..b4794ea 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 { @@ -8,10 +8,14 @@ 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) => - _$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 7a97202..0000000 --- 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 efd7285..0000000 --- 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 ee9c2b8..ab4c49e 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,21 @@ +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'; + +enum LabelType { + correspondent, + documentType, + tag, + storagePath, +} + +sealed class Label extends Equatable implements Comparable { static const idKey = "id"; static const nameKey = "name"; static const slugKey = "slug"; @@ -56,3 +70,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 b05e53e..0000000 --- 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 6052809..0000000 --- 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 d9ebaa8..e5b8495 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/paged_search_result.dart b/packages/paperless_api/lib/src/models/paged_search_result.dart index d727af3..79898d0 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 ac5fc02..136de68 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,19 @@ 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, + userAlreadyExists, + updateSavedViewError; } 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 77cd188..26bfbc3 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/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart b/packages/paperless_api/lib/src/models/permissions/user_permission_extension.dart index 87b4935..d6a0186 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,12 @@ extension UserPermissionExtension on UserModel { hasPermission(PermissionAction.add, PermissionTarget.storagePath); bool get canCreateSavedViews => hasPermission(PermissionAction.add, PermissionTarget.savedView); + + bool get canViewAnyLabel => + canViewCorrespondents || + canViewDocumentTypes || + canViewTags || + canViewStoragePaths; + + bool get canViewInbox => canViewTags && canViewDocuments; } 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 27dbe02..93e371f 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); } 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 ae89590..2918f28 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/models/task/task.dart b/packages/paperless_api/lib/src/models/task/task.dart index 59b863f..43be02b 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/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 fb7e9f2..3ca527a 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,21 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { "password": password, }, options: Options( - validateStatus: (status) => status == 200, + sendTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + 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/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index 4227f83..09560aa 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 016a260..25aabaa 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/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 b373e55..eb5f543 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 e45dbf8..740e713 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/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 8628cdd..ef483cf 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 976ba1c..8b22ee3 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, @@ -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/lib/scan.dart b/packages/paperless_document_scanner/example/lib/scan.dart index aed8a26..a3bf9a1 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; diff --git a/packages/paperless_document_scanner/example/pubspec.lock b/packages/paperless_document_scanner/example/pubspec.lock index a5c1778..433f843 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 1673463..212a30b 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: @@ -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: @@ -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: @@ -381,18 +389,18 @@ packages: dependency: "direct main" description: name: dio - sha256: a9d76e72985d7087eb7c5e7903224ae52b337131518d127c554b9405936752b8 + sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 url: "https://pub.dev" source: hosted - version: "5.2.1+1" + version: "5.3.2" 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: @@ -406,7 +414,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 +438,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: @@ -458,11 +466,27 @@ 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 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: @@ -613,10 +637,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: @@ -629,58 +653,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: @@ -698,10 +722,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 @@ -727,18 +751,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 +784,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5668e6d3dbcb2d0dfa25f7567554b88c57e1e3f3c440b672b24d4a9477017d5b" + url: "https://pub.dev" + source: hosted + version: "10.1.2" + go_router_builder: + dependency: "direct dev" + description: + name: go_router_builder + sha256: "89585f7cf2ddd35a3f05908c5bb54339d3f891fc5aac4f30e2864469d7ddc92b" + url: "https://pub.dev" + source: hosted + version: "2.3.1" graphs: dependency: transitive description: @@ -788,10 +828,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: @@ -865,10 +905,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: @@ -929,42 +969,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: @@ -977,18 +1017,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: @@ -1024,10 +1064,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: @@ -1080,10 +1120,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: @@ -1092,6 +1132,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: eb7082b4b97487ebc65b3ad3f6f0b7489b96e76840381ed0e06a46fe7ffd4068 + url: "https://pub.dev" + source: hosted + version: "0.3.3+3" paperless_api: dependency: "direct main" description: @@ -1127,50 +1175,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" pdf: dependency: "direct main" description: @@ -1183,34 +1231,34 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "415af30ba76a84faccfe1eb251fe1e4fdc790f876924c65ad7d6ed7a1404bcd6" + sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 url: "https://pub.dev" source: hosted - version: "10.4.2" + version: "10.4.5" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "3b61f3da3b1c83bc3fb6a2b431e8dab01d0e5b45f6a3d9c7609770ec88b2a89e" + sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" url: "https://pub.dev" source: hosted - version: "10.3.0" + version: "10.3.6" 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: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 url: "https://pub.dev" source: hosted - version: "3.11.1" + version: "3.11.5" permission_handler_windows: dependency: transitive description: @@ -1247,18 +1295,18 @@ packages: 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" 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: @@ -1391,10 +1439,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: @@ -1468,10 +1516,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: @@ -1508,26 +1556,26 @@ 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: 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: @@ -1588,26 +1636,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: @@ -1624,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: @@ -1644,66 +1700,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: "15f5acbf0dce90146a0f5a2c4a002b1814a6303c4c5c075aa2623b2d16156f03" + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 url: "https://pub.dev" source: hosted - version: "6.0.36" + 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: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e" + sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 url: "https://pub.dev" source: hosted - version: "3.0.5" + 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: @@ -1724,10 +1780,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: @@ -1736,6 +1792,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: @@ -1756,42 +1820,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: "1c93e96f3069bacdc734fad6b7e1d3a480fd516a3ae5b8858becf7f07515a2f3" + sha256: "9427774649fd3c8b7ff53523051395d13aed2ca355822b822e6493d79f5fc05a" url: "https://pub.dev" source: hosted - version: "3.8.2" + version: "3.10.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "656e2aeaef318900fffd21468b6ddc7958c7092a642f0e7220bac328b70d4a81" + sha256: "6d9213c65f1060116757a7c473247c60f3f7f332cac33dc417c9e362a9a13e4f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.6.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: a8d7e8b4be2a79e83b70235369971ec97d14df4cdbb40d305a8eeae67d8e6432 + sha256: d2f7241849582da80b79acb03bb936422412ce5c0c79fb5f6a1de5421a5aecc4 url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "3.7.4" win32: dependency: transitive description: @@ -1825,5 +1889,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" diff --git a/pubspec.yaml b/pubspec.yaml index 3b44c50..1e5f856 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: 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 @@ -90,6 +90,12 @@ dependencies: webview_flutter: ^4.2.1 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 + defer_pointer: ^0.0.2 + transparent_image: ^2.0.1 + flutter_animate: ^4.2.0+1 dependency_overrides: intl: ^0.18.1 @@ -113,6 +119,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