diff --git a/.gitignore b/.gitignore index 9e5fafc..34bba21 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ android/key.properties # VS Code which you may wish to be included in version control, so this line # is commented out by default. .vscode/ +*.code-workspace # Flutter/Dart/Pub related **/doc/api/ diff --git a/README.md b/README.md index 9381d68..8866661 100644 --- a/README.md +++ b/README.md @@ -227,3 +227,6 @@ Here are some impressions from the app! Made with [contrib.rocks](https://contrib.rocks). +## Troubleshooting +#### Suggestions are not selectable in any of the label form fields +This is a known issue and it has to do with accessibility features of Android. Password managers such as Bitwarden often caused this issue to occur. Luckily, this can be resolved by turning off the accessibility features in these apps. diff --git a/android/app/build.gradle b/android/app/build.gradle index c7a26c2..20e446a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -68,11 +68,11 @@ android { storePassword keystoreProperties['storePassword'] } } - buildTypes { - release { - signingConfig signingConfigs.release - } - } + buildTypes { + release { + signingConfig signingConfigs.release + } + } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 842ea55..2c66854 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -35,7 +35,7 @@ PODS: - DKPhotoGallery/Resource (0.0.17): - SDWebImage - SwiftyGif - - edge_detection (1.0.9): + - edge_detection (1.1.1): - Flutter - WeScan - file_picker (0.0.1): @@ -44,6 +44,8 @@ PODS: - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): - Flutter + - flutter_local_notifications (0.0.1): + - Flutter - flutter_native_splash (0.0.1): - Flutter - fluttertoast (0.0.2): @@ -56,10 +58,13 @@ PODS: - Flutter - local_auth_ios (0.0.1): - Flutter + - open_filex (0.0.2): + - Flutter - package_info_plus (0.4.5): - Flutter - - path_provider_ios (0.0.1): + - path_provider_foundation (0.0.1): - Flutter + - FlutterMacOS - pdfx (1.0.0): - Flutter - permission_handler_apple (9.0.4): @@ -72,8 +77,9 @@ PODS: - SDWebImage/Core (5.13.5) - share_plus (0.0.1): - Flutter - - shared_preferences_ios (0.0.1): + - shared_preferences_foundation (0.0.1): - Flutter + - FlutterMacOS - sqflite (0.0.2): - Flutter - FMDB (>= 2.7.5) @@ -90,17 +96,19 @@ DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - 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`) - fluttertoast (from `.symlinks/plugins/fluttertoast/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_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pdfx (from `.symlinks/plugins/pdfx/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -128,6 +136,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" fluttertoast: @@ -136,10 +146,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" local_auth_ios: :path: ".symlinks/plugins/local_auth_ios/ios" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" pdfx: :path: ".symlinks/plugins/pdfx/ios" permission_handler_apple: @@ -148,8 +160,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/receive_sharing_intent/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_ios: - :path: ".symlinks/plugins/shared_preferences_ios/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: @@ -160,28 +172,30 @@ SPEC CHECKSUMS: device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - edge_detection: 9bc5ee35073b5a17c0b3b679908f01017ce3062a - file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + edge_detection: fa02aa120e00d87ada0ca2430b6c6087a501b1e9 + file_picker: ce3938a0df3cc1ef404671531facef740d03f920 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - fluttertoast: 74526702fea2c060ea55dde75895b7e1bde1c86b + fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d + open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 - shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2 WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7 PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 664648c..1653b15 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -321,10 +321,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -335,6 +337,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6cb665f..a837d1a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -65,5 +65,7 @@ CADisableMinimumFrameDurationOnPhone - + UIApplicationSupportsIndirectInputEvents + + diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 0000000..2a86892 --- /dev/null +++ b/lib/constants.dart @@ -0,0 +1,7 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +// Globally accessible variables which are definitely initialized after main(). +late final PackageInfo packageInfo; +late final AndroidDeviceInfo? androidInfo; +late final IosDeviceInfo? iosInfo; diff --git a/lib/core/bloc/paperless_server_information_cubit.dart b/lib/core/bloc/paperless_server_information_cubit.dart index 3c48eca..3067d8f 100644 --- a/lib/core/bloc/paperless_server_information_cubit.dart +++ b/lib/core/bloc/paperless_server_information_cubit.dart @@ -1,7 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart'; -import 'package:paperless_mobile/core/security/session_manager.dart'; class PaperlessServerInformationCubit extends Cubit { diff --git a/lib/core/model/paperless_statistics_state.dart b/lib/core/model/paperless_statistics_state.dart deleted file mode 100644 index 12fede2..0000000 --- a/lib/core/model/paperless_statistics_state.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:paperless_api/paperless_api.dart'; - -class PaperlessStatisticsState { - final bool isLoaded; - final PaperlessServerStatisticsModel? statistics; - - PaperlessStatisticsState({ - required this.isLoaded, - this.statistics, - }); -} diff --git a/lib/core/notifier/document_changed_notifier.dart b/lib/core/notifier/document_changed_notifier.dart new file mode 100644 index 0000000..c53dedc --- /dev/null +++ b/lib/core/notifier/document_changed_notifier.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:rxdart/subjects.dart'; + +typedef DocumentChangedCallback = void Function(DocumentModel document); + +class DocumentChangedNotifier { + final Subject _updated = PublishSubject(); + final Subject _deleted = PublishSubject(); + + final Map> _subscribers = {}; + + void notifyUpdated(DocumentModel updated) { + debugPrint("Notifying updated document ${updated.id}"); + _updated.add(updated); + } + + void notifyDeleted(DocumentModel deleted) { + debugPrint("Notifying deleted document ${deleted.id}"); + _deleted.add(deleted); + } + + void subscribe( + dynamic subscriber, { + DocumentChangedCallback? onUpdated, + DocumentChangedCallback? onDeleted, + }) { + _subscribers.putIfAbsent( + subscriber, + () => [ + _updated.listen((value) { + onUpdated?.call(value); + }), + _deleted.listen((value) { + onDeleted?.call(value); + }), + ], + ); + } + + void unsubscribe(dynamic subscriber) { + _subscribers[subscriber]?.forEach((element) { + element.cancel(); + }); + } + + void close() { + _updated.close(); + _deleted.close(); + } +} diff --git a/lib/core/repository/base_repository.dart b/lib/core/repository/base_repository.dart index f0c4589..1eb57c4 100644 --- a/lib/core/repository/base_repository.dart +++ b/lib/core/repository/base_repository.dart @@ -1,30 +1,30 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; import 'package:rxdart/subjects.dart'; /// /// Base repository class which all repositories should implement /// -abstract class BaseRepository - extends Cubit with HydratedMixin { - final State _initialState; +abstract class BaseRepository extends Cubit> + with HydratedMixin { + final IndexedRepositoryState _initialState; BaseRepository(this._initialState) : super(_initialState) { hydrate(); } - Stream get values => + Stream?> get values => BehaviorSubject.seeded(state)..addStream(super.stream); - State? get current => state; + IndexedRepositoryState? get current => state; bool get isInitialized => state.hasLoaded; - Future create(Type object); - Future find(int id); - Future> findAll([Iterable? ids]); - Future update(Type object); - Future delete(Type object); + Future create(T object); + Future find(int id); + Future> findAll([Iterable? ids]); + Future update(T object); + Future delete(T object); @override Future clear() async { diff --git a/lib/core/repository/impl/correspondent_repository_impl.dart b/lib/core/repository/impl/correspondent_repository_impl.dart index 4b676ac..7227c58 100644 --- a/lib/core/repository/impl/correspondent_repository_impl.dart +++ b/lib/core/repository/impl/correspondent_repository_impl.dart @@ -3,10 +3,8 @@ import 'dart:async'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; -class CorrespondentRepositoryImpl - extends LabelRepository { +class CorrespondentRepositoryImpl extends LabelRepository { final PaperlessLabelsApi _api; CorrespondentRepositoryImpl(this._api) @@ -15,7 +13,7 @@ class CorrespondentRepositoryImpl @override Future create(Correspondent correspondent) async { final created = await _api.saveCorrespondent(correspondent); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..putIfAbsent(created.id!, () => created); emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true)); return created; @@ -24,7 +22,7 @@ class CorrespondentRepositoryImpl @override Future delete(Correspondent correspondent) async { await _api.deleteCorrespondent(correspondent); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..removeWhere((k, v) => k == correspondent.id); emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true)); return correspondent.id!; @@ -34,7 +32,7 @@ class CorrespondentRepositoryImpl Future find(int id) async { final correspondent = await _api.getCorrespondent(id); if (correspondent != null) { - final updatedState = {...state.values}..[id] = correspondent; + final updatedState = {...state.values ?? {}}..[id] = correspondent; emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true)); return correspondent; } @@ -44,7 +42,7 @@ class CorrespondentRepositoryImpl @override Future> findAll([Iterable? ids]) async { final correspondents = await _api.getCorrespondents(ids); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..addEntries(correspondents.map((e) => MapEntry(e.id!, e))); emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true)); return correspondents; @@ -53,7 +51,8 @@ class CorrespondentRepositoryImpl @override Future update(Correspondent correspondent) async { final updated = await _api.updateCorrespondent(correspondent); - final updatedState = {...state.values}..update(updated.id!, (_) => updated); + final updatedState = {...state.values ?? {}} + ..update(updated.id!, (_) => updated); emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true)); return updated; } @@ -64,7 +63,7 @@ class CorrespondentRepositoryImpl } @override - Map toJson(CorrespondentRepositoryState state) { + Map toJson(covariant CorrespondentRepositoryState state) { return state.toJson(); } } diff --git a/lib/core/repository/impl/document_type_repository_impl.dart b/lib/core/repository/impl/document_type_repository_impl.dart index 1e1ae92..5fd7a87 100644 --- a/lib/core/repository/impl/document_type_repository_impl.dart +++ b/lib/core/repository/impl/document_type_repository_impl.dart @@ -1,10 +1,8 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; -import 'package:rxdart/rxdart.dart' show BehaviorSubject; -class DocumentTypeRepositoryImpl - extends LabelRepository { +class DocumentTypeRepositoryImpl extends LabelRepository { final PaperlessLabelsApi _api; DocumentTypeRepositoryImpl(this._api) @@ -13,7 +11,7 @@ class DocumentTypeRepositoryImpl @override Future create(DocumentType documentType) async { final created = await _api.saveDocumentType(documentType); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..putIfAbsent(created.id!, () => created); emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true)); return created; @@ -22,7 +20,7 @@ class DocumentTypeRepositoryImpl @override Future delete(DocumentType documentType) async { await _api.deleteDocumentType(documentType); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..removeWhere((k, v) => k == documentType.id); emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true)); return documentType.id!; @@ -32,7 +30,7 @@ class DocumentTypeRepositoryImpl Future find(int id) async { final documentType = await _api.getDocumentType(id); if (documentType != null) { - final updatedState = {...state.values}..[id] = documentType; + final updatedState = {...state.values ?? {}}..[id] = documentType; emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true)); return documentType; } @@ -42,7 +40,7 @@ class DocumentTypeRepositoryImpl @override Future> findAll([Iterable? ids]) async { final documentTypes = await _api.getDocumentTypes(ids); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..addEntries(documentTypes.map((e) => MapEntry(e.id!, e))); emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true)); return documentTypes; @@ -51,7 +49,8 @@ class DocumentTypeRepositoryImpl @override Future update(DocumentType documentType) async { final updated = await _api.updateDocumentType(documentType); - final updatedState = {...state.values}..update(updated.id!, (_) => updated); + final updatedState = {...state.values ?? {}} + ..update(updated.id!, (_) => updated); emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true)); return updated; } @@ -62,7 +61,7 @@ class DocumentTypeRepositoryImpl } @override - Map toJson(DocumentTypeRepositoryState state) { + Map toJson(covariant DocumentTypeRepositoryState state) { return state.toJson(); } } diff --git a/lib/core/repository/impl/saved_view_repository_impl.dart b/lib/core/repository/impl/saved_view_repository_impl.dart index b5e03aa..18eceed 100644 --- a/lib/core/repository/impl/saved_view_repository_impl.dart +++ b/lib/core/repository/impl/saved_view_repository_impl.dart @@ -10,7 +10,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository { @override Future create(SavedView object) async { final created = await _api.save(object); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..putIfAbsent(created.id!, () => created); emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true)); return created; @@ -19,7 +19,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository { @override Future delete(SavedView view) async { await _api.delete(view); - final updatedState = {...state.values}..remove(view.id); + final updatedState = {...state.values ?? {}}..remove(view.id); emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true)); return view.id!; } @@ -27,7 +27,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository { @override Future find(int id) async { final found = await _api.find(id); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..update(id, (_) => found, ifAbsent: () => found); emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true)); return found; @@ -37,7 +37,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository { Future> findAll([Iterable? ids]) async { final found = await _api.findAll(ids); final updatedState = { - ...state.values, + ...state.values ?? {}, ...{for (final view in found) view.id!: view}, }; emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true)); @@ -56,7 +56,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository { } @override - Map toJson(SavedViewRepositoryState state) { + Map toJson(covariant SavedViewRepositoryState state) { return state.toJson(); } } diff --git a/lib/core/repository/impl/storage_path_repository_impl.dart b/lib/core/repository/impl/storage_path_repository_impl.dart index b738827..1fb54ea 100644 --- a/lib/core/repository/impl/storage_path_repository_impl.dart +++ b/lib/core/repository/impl/storage_path_repository_impl.dart @@ -3,8 +3,7 @@ import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; import 'package:rxdart/rxdart.dart' show BehaviorSubject; -class StoragePathRepositoryImpl - extends LabelRepository { +class StoragePathRepositoryImpl extends LabelRepository { final PaperlessLabelsApi _api; StoragePathRepositoryImpl(this._api) @@ -13,7 +12,7 @@ class StoragePathRepositoryImpl @override Future create(StoragePath storagePath) async { final created = await _api.saveStoragePath(storagePath); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..putIfAbsent(created.id!, () => created); emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true)); return created; @@ -22,7 +21,7 @@ class StoragePathRepositoryImpl @override Future delete(StoragePath storagePath) async { await _api.deleteStoragePath(storagePath); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..removeWhere((k, v) => k == storagePath.id); emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true)); return storagePath.id!; @@ -32,7 +31,7 @@ class StoragePathRepositoryImpl Future find(int id) async { final storagePath = await _api.getStoragePath(id); if (storagePath != null) { - final updatedState = {...state.values}..[id] = storagePath; + final updatedState = {...state.values ?? {}}..[id] = storagePath; emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true)); return storagePath; } @@ -42,7 +41,7 @@ class StoragePathRepositoryImpl @override Future> findAll([Iterable? ids]) async { final storagePaths = await _api.getStoragePaths(ids); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..addEntries(storagePaths.map((e) => MapEntry(e.id!, e))); emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true)); return storagePaths; @@ -51,7 +50,8 @@ class StoragePathRepositoryImpl @override Future update(StoragePath storagePath) async { final updated = await _api.updateStoragePath(storagePath); - final updatedState = {...state.values}..update(updated.id!, (_) => updated); + final updatedState = {...state.values ?? {}} + ..update(updated.id!, (_) => updated); emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true)); return updated; } @@ -62,7 +62,7 @@ class StoragePathRepositoryImpl } @override - Map toJson(StoragePathRepositoryState state) { + Map toJson(covariant StoragePathRepositoryState state) { return state.toJson(); } } diff --git a/lib/core/repository/impl/tag_repository_impl.dart b/lib/core/repository/impl/tag_repository_impl.dart index 09f6061..a39a77b 100644 --- a/lib/core/repository/impl/tag_repository_impl.dart +++ b/lib/core/repository/impl/tag_repository_impl.dart @@ -1,10 +1,8 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; -class TagRepositoryImpl extends LabelRepository { +class TagRepositoryImpl extends LabelRepository { final PaperlessLabelsApi _api; TagRepositoryImpl(this._api) : super(const TagRepositoryState()); @@ -12,7 +10,7 @@ class TagRepositoryImpl extends LabelRepository { @override Future create(Tag object) async { final created = await _api.saveTag(object); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..putIfAbsent(created.id!, () => created); emit(TagRepositoryState(values: updatedState, hasLoaded: true)); return created; @@ -21,7 +19,8 @@ class TagRepositoryImpl extends LabelRepository { @override Future delete(Tag tag) async { await _api.deleteTag(tag); - final updatedState = {...state.values}..removeWhere((k, v) => k == tag.id); + final updatedState = {...state.values ?? {}} + ..removeWhere((k, v) => k == tag.id); emit(TagRepositoryState(values: updatedState, hasLoaded: true)); return tag.id!; } @@ -30,7 +29,7 @@ class TagRepositoryImpl extends LabelRepository { Future find(int id) async { final tag = await _api.getTag(id); if (tag != null) { - final updatedState = {...state.values}..[id] = tag; + final updatedState = {...state.values ?? {}}..[id] = tag; emit(TagRepositoryState(values: updatedState, hasLoaded: true)); return tag; } @@ -40,7 +39,7 @@ class TagRepositoryImpl extends LabelRepository { @override Future> findAll([Iterable? ids]) async { final tags = await _api.getTags(ids); - final updatedState = {...state.values} + final updatedState = {...state.values ?? {}} ..addEntries(tags.map((e) => MapEntry(e.id!, e))); emit(TagRepositoryState(values: updatedState, hasLoaded: true)); return tags; @@ -49,7 +48,8 @@ class TagRepositoryImpl extends LabelRepository { @override Future update(Tag tag) async { final updated = await _api.updateTag(tag); - final updatedState = {...state.values}..update(updated.id!, (_) => updated); + final updatedState = {...state.values ?? {}} + ..update(updated.id!, (_) => updated); emit(TagRepositoryState(values: updatedState, hasLoaded: true)); return updated; } @@ -60,7 +60,7 @@ class TagRepositoryImpl extends LabelRepository { } @override - Map? toJson(TagRepositoryState state) { + Map? toJson(covariant TagRepositoryState state) { return state.toJson(); } } diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index c2aa3bc..8fe3458 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -1,8 +1,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/base_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; -abstract class LabelRepository - extends BaseRepository { - LabelRepository(State initial) : super(initial); +abstract class LabelRepository extends BaseRepository { + LabelRepository(IndexedRepositoryState initial) : super(initial); } diff --git a/lib/core/repository/provider/label_repositories_provider.dart b/lib/core/repository/provider/label_repositories_provider.dart index d45c792..e9634be 100644 --- a/lib/core/repository/provider/label_repositories_provider.dart +++ b/lib/core/repository/provider/label_repositories_provider.dart @@ -17,20 +17,16 @@ class LabelRepositoriesProvider extends StatelessWidget { return MultiRepositoryProvider( providers: [ RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), ), RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), ), RepositoryProvider( - create: (context) => context - .read>(), + create: (context) => context.read>(), ), RepositoryProvider( - create: (context) => - context.read>(), + create: (context) => context.read>(), ), ], child: child, diff --git a/lib/core/repository/saved_view_repository.dart b/lib/core/repository/saved_view_repository.dart index 644f367..bb1c4e3 100644 --- a/lib/core/repository/saved_view_repository.dart +++ b/lib/core/repository/saved_view_repository.dart @@ -1,8 +1,8 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/base_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/saved_view_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; -abstract class SavedViewRepository - extends BaseRepository { +abstract class SavedViewRepository extends BaseRepository { SavedViewRepository(super.initialState); } diff --git a/lib/core/repository/state/impl/correspondent_repository_state.dart b/lib/core/repository/state/impl/correspondent_repository_state.dart index 5fb88ee..fce9efb 100644 --- a/lib/core/repository/state/impl/correspondent_repository_state.dart +++ b/lib/core/repository/state/impl/correspondent_repository_state.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; part 'correspondent_repository_state.g.dart'; @JsonSerializable() class CorrespondentRepositoryState - extends RepositoryState> { + extends IndexedRepositoryState { const CorrespondentRepositoryState({ super.values = const {}, super.hasLoaded, diff --git a/lib/core/repository/state/impl/correspondent_repository_state.g.dart b/lib/core/repository/state/impl/correspondent_repository_state.g.dart index 08e2976..405f4ff 100644 --- a/lib/core/repository/state/impl/correspondent_repository_state.g.dart +++ b/lib/core/repository/state/impl/correspondent_repository_state.g.dart @@ -20,6 +20,6 @@ CorrespondentRepositoryState _$CorrespondentRepositoryStateFromJson( Map _$CorrespondentRepositoryStateToJson( CorrespondentRepositoryState instance) => { - 'values': instance.values.map((k, e) => MapEntry(k.toString(), e)), + 'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)), 'hasLoaded': instance.hasLoaded, }; diff --git a/lib/core/repository/state/impl/document_type_repository_state.dart b/lib/core/repository/state/impl/document_type_repository_state.dart index 7ce5188..4a4ab1f 100644 --- a/lib/core/repository/state/impl/document_type_repository_state.dart +++ b/lib/core/repository/state/impl/document_type_repository_state.dart @@ -1,20 +1,21 @@ import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; import 'package:json_annotation/json_annotation.dart'; part 'document_type_repository_state.g.dart'; @JsonSerializable() -class DocumentTypeRepositoryState - extends RepositoryState> { +class DocumentTypeRepositoryState extends IndexedRepositoryState { const DocumentTypeRepositoryState({ super.values = const {}, super.hasLoaded, }); @override - DocumentTypeRepositoryState copyWith( - {Map? values, bool? hasLoaded}) { + DocumentTypeRepositoryState copyWith({ + Map? values, + bool? hasLoaded, + }) { return DocumentTypeRepositoryState( values: values ?? this.values, hasLoaded: hasLoaded ?? this.hasLoaded, diff --git a/lib/core/repository/state/impl/document_type_repository_state.g.dart b/lib/core/repository/state/impl/document_type_repository_state.g.dart index 6868bd6..3528b96 100644 --- a/lib/core/repository/state/impl/document_type_repository_state.g.dart +++ b/lib/core/repository/state/impl/document_type_repository_state.g.dart @@ -20,6 +20,6 @@ DocumentTypeRepositoryState _$DocumentTypeRepositoryStateFromJson( Map _$DocumentTypeRepositoryStateToJson( DocumentTypeRepositoryState instance) => { - 'values': instance.values.map((k, e) => MapEntry(k.toString(), e)), + 'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)), 'hasLoaded': instance.hasLoaded, }; diff --git a/lib/core/repository/state/impl/saved_view_repository_state.dart b/lib/core/repository/state/impl/saved_view_repository_state.dart index ecd9e49..9cd7672 100644 --- a/lib/core/repository/state/impl/saved_view_repository_state.dart +++ b/lib/core/repository/state/impl/saved_view_repository_state.dart @@ -1,11 +1,11 @@ import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; import 'package:json_annotation/json_annotation.dart'; part 'saved_view_repository_state.g.dart'; @JsonSerializable() -class SavedViewRepositoryState extends RepositoryState> { +class SavedViewRepositoryState extends IndexedRepositoryState { const SavedViewRepositoryState({ super.values = const {}, super.hasLoaded = false, diff --git a/lib/core/repository/state/impl/saved_view_repository_state.g.dart b/lib/core/repository/state/impl/saved_view_repository_state.g.dart index 4cc61b9..bfcc949 100644 --- a/lib/core/repository/state/impl/saved_view_repository_state.g.dart +++ b/lib/core/repository/state/impl/saved_view_repository_state.g.dart @@ -20,6 +20,6 @@ SavedViewRepositoryState _$SavedViewRepositoryStateFromJson( Map _$SavedViewRepositoryStateToJson( SavedViewRepositoryState instance) => { - 'values': instance.values.map((k, e) => MapEntry(k.toString(), e)), + 'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)), 'hasLoaded': instance.hasLoaded, }; diff --git a/lib/core/repository/state/impl/storage_path_repository_state.dart b/lib/core/repository/state/impl/storage_path_repository_state.dart index 366db8e..b9ed856 100644 --- a/lib/core/repository/state/impl/storage_path_repository_state.dart +++ b/lib/core/repository/state/impl/storage_path_repository_state.dart @@ -1,20 +1,21 @@ import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; import 'package:json_annotation/json_annotation.dart'; part 'storage_path_repository_state.g.dart'; @JsonSerializable() -class StoragePathRepositoryState - extends RepositoryState> { +class StoragePathRepositoryState extends IndexedRepositoryState { const StoragePathRepositoryState({ super.values = const {}, super.hasLoaded = false, }); @override - StoragePathRepositoryState copyWith( - {Map? values, bool? hasLoaded}) { + StoragePathRepositoryState copyWith({ + Map? values, + bool? hasLoaded, + }) { return StoragePathRepositoryState( values: values ?? this.values, hasLoaded: hasLoaded ?? this.hasLoaded, diff --git a/lib/core/repository/state/impl/storage_path_repository_state.g.dart b/lib/core/repository/state/impl/storage_path_repository_state.g.dart index 4be8ad5..75ac365 100644 --- a/lib/core/repository/state/impl/storage_path_repository_state.g.dart +++ b/lib/core/repository/state/impl/storage_path_repository_state.g.dart @@ -20,6 +20,6 @@ StoragePathRepositoryState _$StoragePathRepositoryStateFromJson( Map _$StoragePathRepositoryStateToJson( StoragePathRepositoryState instance) => { - 'values': instance.values.map((k, e) => MapEntry(k.toString(), e)), + 'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)), 'hasLoaded': instance.hasLoaded, }; diff --git a/lib/core/repository/state/impl/tag_repository_state.dart b/lib/core/repository/state/impl/tag_repository_state.dart index 6e0e261..4558bfe 100644 --- a/lib/core/repository/state/impl/tag_repository_state.dart +++ b/lib/core/repository/state/impl/tag_repository_state.dart @@ -1,18 +1,21 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; part 'tag_repository_state.g.dart'; @JsonSerializable() -class TagRepositoryState extends RepositoryState> { +class TagRepositoryState extends IndexedRepositoryState { const TagRepositoryState({ super.values = const {}, super.hasLoaded = false, }); @override - TagRepositoryState copyWith({Map? values, bool? hasLoaded}) { + TagRepositoryState copyWith({ + Map? values, + bool? hasLoaded, + }) { return TagRepositoryState( values: values ?? this.values, hasLoaded: hasLoaded ?? this.hasLoaded, diff --git a/lib/core/repository/state/impl/tag_repository_state.g.dart b/lib/core/repository/state/impl/tag_repository_state.g.dart index 02e8bd0..09e04ee 100644 --- a/lib/core/repository/state/impl/tag_repository_state.g.dart +++ b/lib/core/repository/state/impl/tag_repository_state.g.dart @@ -18,6 +18,6 @@ TagRepositoryState _$TagRepositoryStateFromJson(Map json) => Map _$TagRepositoryStateToJson(TagRepositoryState instance) => { - 'values': instance.values.map((k, e) => MapEntry(k.toString(), e)), + 'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)), 'hasLoaded': instance.hasLoaded, }; diff --git a/lib/core/repository/state/indexed_repository_state.dart b/lib/core/repository/state/indexed_repository_state.dart new file mode 100644 index 0000000..d3caee5 --- /dev/null +++ b/lib/core/repository/state/indexed_repository_state.dart @@ -0,0 +1,16 @@ +abstract class IndexedRepositoryState { + final Map? values; + final bool hasLoaded; + + const IndexedRepositoryState({ + required this.values, + this.hasLoaded = false, + }) : assert(!(values == null) || !hasLoaded); + + IndexedRepositoryState.loaded(this.values) : hasLoaded = true; + + IndexedRepositoryState copyWith({ + Map? values, + bool? hasLoaded, + }); +} diff --git a/lib/core/repository/state/repository_state.dart b/lib/core/repository/state/repository_state.dart deleted file mode 100644 index 7498a33..0000000 --- a/lib/core/repository/state/repository_state.dart +++ /dev/null @@ -1,16 +0,0 @@ -abstract class RepositoryState { - final T values; - final bool hasLoaded; - - const RepositoryState({ - required this.values, - this.hasLoaded = false, - }); - - RepositoryState.loaded(this.values) : hasLoaded = true; - - RepositoryState copyWith({ - T? values, - bool? hasLoaded, - }); -} diff --git a/lib/core/security/security_context_utils.dart b/lib/core/security/security_context_utils.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/core/service/github_issue_service.dart b/lib/core/service/github_issue_service.dart index 38568b9..b4e1d79 100644 --- a/lib/core/service/github_issue_service.dart +++ b/lib/core/service/github_issue_service.dart @@ -27,7 +27,7 @@ class GithubIssueService { ..tryPutIfAbsent('assignees', () => assignees?.join(',')) ..tryPutIfAbsent('project', () => project), ); - log("[GitHubIssueService] Creating GitHub issue: " + uri.toString()); + debugPrint("[GitHubIssueService] Creating GitHub issue: " + uri.toString()); launchUrl( uri, mode: LaunchMode.externalApplication, diff --git a/lib/core/service/status_service.dart b/lib/core/service/status_service.dart index 76b2428..dce014d 100644 --- a/lib/core/service/status_service.dart +++ b/lib/core/service/status_service.dart @@ -8,7 +8,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/document_status_cubit.dart'; import 'package:paperless_mobile/core/model/document_processing_status.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/constants.dart'; import 'package:web_socket_channel/io.dart'; abstract class StatusService { diff --git a/lib/core/store/local_vault.dart b/lib/core/store/local_vault.dart deleted file mode 100644 index 9859e8f..0000000 --- a/lib/core/store/local_vault.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:convert'; - -import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; -import 'package:flutter/foundation.dart'; -import 'package:paperless_mobile/core/type/types.dart'; -import 'package:paperless_mobile/features/login/model/authentication_information.dart'; -import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; - -abstract class LocalVault { - Future storeAuthenticationInformation(AuthenticationInformation auth); - Future loadAuthenticationInformation(); - Future loadCertificate(); - Future storeApplicationSettings(ApplicationSettingsState settings); - Future loadApplicationSettings(); - Future clear(); -} - -class LocalVaultImpl implements LocalVault { - static const applicationSettingsKey = "applicationSettings"; - static const authenticationKey = "authentication"; - - final EncryptedSharedPreferences sharedPreferences; - - LocalVaultImpl(this.sharedPreferences); - - @override - Future storeAuthenticationInformation( - AuthenticationInformation auth, - ) async { - await sharedPreferences.setString( - authenticationKey, - jsonEncode(auth.toJson()), - ); - } - - @override - Future loadAuthenticationInformation() async { - if ((await sharedPreferences.getString(authenticationKey)).isEmpty) { - return null; - } - return AuthenticationInformation.fromJson( - jsonDecode(await sharedPreferences.getString(authenticationKey)), - ); - } - - @override - Future loadCertificate() async { - return loadAuthenticationInformation() - .then((value) => value?.clientCertificate); - } - - @override - Future storeApplicationSettings(ApplicationSettingsState settings) { - return sharedPreferences.setString( - applicationSettingsKey, - jsonEncode(settings.toJson()), - ); - } - - @override - Future loadApplicationSettings() async { - final settings = await sharedPreferences.getString(applicationSettingsKey); - if (settings.isEmpty) { - return null; - } - return compute( - ApplicationSettingsState.fromJson, - jsonDecode(settings) as JSON, - ); - } - - @override - Future clear() { - return sharedPreferences.clear(); - } -} diff --git a/lib/core/translation/color_scheme_option_localization_mapper.dart b/lib/core/translation/color_scheme_option_localization_mapper.dart new file mode 100644 index 0000000..c4b3f76 --- /dev/null +++ b/lib/core/translation/color_scheme_option_localization_mapper.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +String translateColorSchemeOption( + BuildContext context, ColorSchemeOption option) { + switch (option) { + case ColorSchemeOption.classic: + return S.of(context).colorSchemeOptionClassic; + case ColorSchemeOption.dynamic: + return S.of(context).colorSchemeOptionDynamic; + } +} diff --git a/lib/core/translation/sort_field_localization_mapper.dart b/lib/core/translation/sort_field_localization_mapper.dart new file mode 100644 index 0000000..e707b6e --- /dev/null +++ b/lib/core/translation/sort_field_localization_mapper.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +String translateSortField(BuildContext context, SortField? sortField) { + switch (sortField) { + case SortField.archiveSerialNumber: + return S.of(context).documentArchiveSerialNumberPropertyShortLabel; + case SortField.correspondentName: + return S.of(context).documentCorrespondentPropertyLabel; + case SortField.title: + return S.of(context).documentTitlePropertyLabel; + case SortField.documentType: + return S.of(context).documentDocumentTypePropertyLabel; + case SortField.created: + return S.of(context).documentCreatedPropertyLabel; + case SortField.added: + return S.of(context).documentAddedPropertyLabel; + case SortField.modified: + return S.of(context).documentModifiedPropertyLabel; + default: + return ''; + } +} diff --git a/lib/core/type/types.dart b/lib/core/type/types.dart index 3ed65fa..a133cbf 100644 --- a/lib/core/type/types.dart +++ b/lib/core/type/types.dart @@ -1,3 +1,6 @@ +import 'package:paperless_api/paperless_api.dart'; +import 'package:rxdart/subjects.dart'; + typedef JSON = Map; typedef PaperlessValidationErrors = Map; typedef PaperlessLocalizedErrorMessage = String; diff --git a/lib/core/widgets/app_options_popup_menu.dart b/lib/core/widgets/app_options_popup_menu.dart new file mode 100644 index 0000000..f02feb5 --- /dev/null +++ b/lib/core/widgets/app_options_popup_menu.dart @@ -0,0 +1,217 @@ +// 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.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).appDrawerReportBugLabel), +// ), +// ); +// } + +// 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).appDrawerSettingsLabel), +// ), +// ); +// } + +// 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).appDrawerAboutLabel), +// ), +// ); +// } + +// 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).aboutDialogDevelopedByText('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/documents_list_loading_widget.dart b/lib/core/widgets/documents_list_loading_widget.dart deleted file mode 100644 index 6f0f920..0000000 --- a/lib/core/widgets/documents_list_loading_widget.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:shimmer/shimmer.dart'; - -class DocumentsListLoadingWidget extends StatelessWidget { - final List above; - final List below; - static const tags = [" ", " ", " "]; - static const titleLengths = [double.infinity, 150.0, 200.0]; - static const correspondentLengths = [200.0, 300.0, 150.0]; - static const fontSize = 16.0; - - const DocumentsListLoadingWidget({ - super.key, - this.above = const [], - this.below = const [], - }); - - @override - Widget build(BuildContext context) { - return ListView( - children: [ - ...above, - ...List.generate(25, (idx) { - final r = Random(idx); - final tagCount = r.nextInt(tags.length + 1); - final correspondentLength = - correspondentLengths[r.nextInt(correspondentLengths.length - 1)]; - final titleLength = titleLengths[r.nextInt(titleLengths.length - 1)]; - return Shimmer.fromColors( - baseColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[300]! - : Colors.grey[900]!, - highlightColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[100]! - : Colors.grey[600]!, - child: ListTile( - contentPadding: const EdgeInsets.all(8), - dense: true, - isThreeLine: true, - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.white, - height: 50, - width: 35, - ), - ), - title: Container( - padding: const EdgeInsets.symmetric(vertical: 2.0), - width: correspondentLength, - height: fontSize, - color: Colors.white, - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 2.0), - height: fontSize, - width: titleLength, - color: Colors.white, - ), - Wrap( - spacing: 2.0, - children: List.generate( - tagCount, - (index) => InputChip( - label: Text(tags[r.nextInt(tags.length)]), - ), - ), - ).paddedOnly(top: 4), - ], - ), - ), - ), - ); - }).toList(), - ...below, - ], - ); - } -} diff --git a/lib/core/widgets/hint_card.dart b/lib/core/widgets/hint_card.dart index 27b6ddb..03a5120 100644 --- a/lib/core/widgets/hint_card.dart +++ b/lib/core/widgets/hint_card.dart @@ -6,6 +6,7 @@ import 'package:paperless_mobile/generated/l10n.dart'; class HintCard extends StatelessWidget { final String hintText; final double elevation; + final IconData hintIcon; final VoidCallback? onHintAcknowledged; final bool show; const HintCard({ @@ -13,7 +14,8 @@ class HintCard extends StatelessWidget { required this.hintText, this.onHintAcknowledged, this.elevation = 1, - required this.show, + this.show = true, + this.hintIcon = Icons.tips_and_updates_outlined, }); @override @@ -31,16 +33,19 @@ class HintCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.tips_and_updates_outlined, + hintIcon, color: Theme.of(context).hintColor, ).padded(), - Align( - alignment: Alignment.center, - child: Text( - hintText, - softWrap: true, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.center, + child: Text( + hintText, + softWrap: true, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), ), ), if (onHintAcknowledged != null) @@ -52,7 +57,7 @@ class HintCard extends StatelessWidget { ), ) else - Padding(padding: EdgeInsets.only(bottom: 24)), + const Padding(padding: EdgeInsets.only(bottom: 24)), ], ).padded(), ).padded(), diff --git a/lib/core/widgets/material/search/m3_search.dart b/lib/core/widgets/material/search/m3_search.dart new file mode 100644 index 0000000..dc01985 --- /dev/null +++ b/lib/core/widgets/material/search/m3_search.dart @@ -0,0 +1,602 @@ +//TODO: REMOVE THIS WHEN NATIVE MATERIAL FLUTTER SEARCH IS RELEASED +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shows a full screen search page and returns the search result selected by +/// the user when the page is closed. +/// +/// The search page consists of an app bar with a search field and a body which +/// can either show suggested search queries or the search results. +/// +/// The appearance of the search page is determined by the provided +/// `delegate`. The initial query string is given by `query`, which defaults +/// to the empty string. When `query` is set to null, `delegate.query` will +/// be used as the initial query. +/// +/// This method returns the selected search result, which can be set in the +/// [SearchDelegate.close] call. If the search page is closed with the system +/// back button, it returns null. +/// +/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showMaterial3Search] call. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// search page to the [Navigator] furthest from or nearest to the given +/// `context`. By default, `useRootNavigator` is `false` and the search page +/// route created by this method is pushed to the nearest navigator to the +/// given `context`. It can not be `null`. +/// +/// The transition to the search page triggered by this method looks best if the +/// screen triggering the transition contains an [AppBar] at the top and the +/// transition is called from an [IconButton] that's part of [AppBar.actions]. +/// The animation provided by [SearchDelegate.transitionAnimation] can be used +/// to trigger additional animations in the underlying page while the search +/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in +/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow +/// used to exit the search page. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// See also: +/// +/// * [SearchDelegate] to define the content of the search page. +Future showMaterial3Search({ + required BuildContext context, + required SearchDelegate delegate, + String? query = '', + bool useRootNavigator = false, +}) { + delegate.query = query ?? delegate.query; + delegate._currentBody = _SearchBody.suggestions; + return Navigator.of(context, rootNavigator: useRootNavigator) + .push(_SearchPageRoute( + delegate: delegate, + )); +} + +/// Delegate for [showMaterial3Search] to define the content of the search page. +/// +/// The search page always shows an [AppBar] at the top where users can +/// enter their search queries. The buttons shown before and after the search +/// query text field can be customized via [SearchDelegate.buildLeading] +/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed +/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom]. +/// +/// The body below the [AppBar] can either show suggested queries (returned by +/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the +/// results of the search as returned by [SearchDelegate.buildResults]. +/// +/// [SearchDelegate.query] always contains the current query entered by the user +/// and should be used to build the suggestions and results. +/// +/// The results can be brought on screen by calling [SearchDelegate.showResults] +/// and you can go back to showing the suggestions by calling +/// [SearchDelegate.showSuggestions]. +/// +/// Once the user has selected a search result, [SearchDelegate.close] should be +/// called to remove the search page from the top of the navigation stack and +/// to notify the caller of [showMaterial3Search] about the selected search result. +/// +/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showMaterial3Search] call. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +abstract class SearchDelegate { + /// Constructor to be called by subclasses which may specify + /// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme], + /// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel] + /// and [searchFieldDecorationTheme] may be non-null. + /// + /// {@tool snippet} + /// ```dart + /// class CustomSearchHintDelegate extends SearchDelegate { + /// CustomSearchHintDelegate({ + /// required String hintText, + /// }) : super( + /// searchFieldLabel: hintText, + /// keyboardType: TextInputType.text, + /// textInputAction: TextInputAction.search, + /// ); + /// + /// @override + /// Widget buildLeading(BuildContext context) => const Text('leading'); + /// + /// @override + /// PreferredSizeWidget buildBottom(BuildContext context) { + /// return const PreferredSize( + /// preferredSize: Size.fromHeight(56.0), + /// child: Text('bottom')); + /// } + /// + /// @override + /// Widget buildSuggestions(BuildContext context) => const Text('suggestions'); + /// + /// @override + /// Widget buildResults(BuildContext context) => const Text('results'); + /// + /// @override + /// List buildActions(BuildContext context) => []; + /// } + /// ``` + /// {@end-tool} + SearchDelegate({ + this.searchFieldLabel, + this.searchFieldStyle, + this.searchFieldDecorationTheme, + this.keyboardType, + this.textInputAction = TextInputAction.search, + }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null); + + /// Suggestions shown in the body of the search page while the user types a + /// query into the search field. + /// + /// The delegate method is called whenever the content of [query] changes. + /// The suggestions should be based on the current [query] string. If the query + /// string is empty, it is good practice to show suggested queries based on + /// past queries or the current context. + /// + /// Usually, this method will return a [ListView] with one [ListTile] per + /// suggestion. When [ListTile.onTap] is called, [query] should be updated + /// with the corresponding suggestion and the results page should be shown + /// by calling [showResults]. + Widget buildSuggestions(BuildContext context); + + /// The results shown after the user submits a search from the search page. + /// + /// The current value of [query] can be used to determine what the user + /// searched for. + /// + /// This method might be applied more than once to the same query. + /// If your [buildResults] method is computationally expensive, you may want + /// to cache the search results for one or more queries. + /// + /// Typically, this method returns a [ListView] with the search results. + /// When the user taps on a particular search result, [close] should be called + /// with the selected result as argument. This will close the search page and + /// communicate the result back to the initial caller of [showMaterial3Search]. + Widget buildResults(BuildContext context); + + /// A widget to display before the current query in the [AppBar]. + /// + /// Typically an [IconButton] configured with a [BackButtonIcon] that exits + /// the search with [close]. One can also use an [AnimatedIcon] driven by + /// [transitionAnimation], which animates from e.g. a hamburger menu to the + /// back button as the search overlay fades in. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.leading], the intended use for the return value of this method. + Widget? buildLeading(BuildContext context); + + /// Widgets to display after the search query in the [AppBar]. + /// + /// If the [query] is not empty, this should typically contain a button to + /// clear the query and show the suggestions again (via [showSuggestions]) if + /// the results are currently shown. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.actions], the intended use for the return value of this method. + List? buildActions(BuildContext context); + + /// Widget to display across the bottom of the [AppBar]. + /// + /// Returns null by default, i.e. a bottom widget is not included. + /// + /// See also: + /// + /// * [AppBar.bottom], the intended use for the return value of this method. + /// + PreferredSizeWidget? buildBottom(BuildContext context) => null; + + /// The theme used to configure the search page. + /// + /// The returned [ThemeData] will be used to wrap the entire search page, + /// so it can be used to configure any of its components with the appropriate + /// theme properties. + /// + /// Unless overridden, the default theme will configure the AppBar containing + /// the search input text field with a white background and black text on light + /// themes. For dark themes the default is a dark grey background with light + /// color text. + /// + /// See also: + /// + /// * [AppBarTheme], which configures the AppBar's appearance. + /// * [InputDecorationTheme], which configures the appearance of the search + /// text field. + ThemeData appBarTheme(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + return theme.copyWith( + appBarTheme: AppBarTheme( + systemOverlayStyle: colorScheme.brightness == Brightness.light + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + backgroundColor: colorScheme.brightness == Brightness.dark + ? Colors.grey[900] + : Colors.white, + iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey), + ), + inputDecorationTheme: searchFieldDecorationTheme ?? + InputDecorationTheme( + hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle, + border: InputBorder.none, + ), + ); + } + + /// The current query string shown in the [AppBar]. + /// + /// The user manipulates this string via the keyboard. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] this + /// string should be updated to that suggestion via the setter. + String get query => _queryTextController.text; + + /// Changes the current query string. + /// + /// Setting the query string programmatically moves the cursor to the end of the text field. + set query(String value) { + assert(query != null); + _queryTextController.text = value; + if (_queryTextController.text.isNotEmpty) { + _queryTextController.selection = TextSelection.fromPosition( + TextPosition(offset: _queryTextController.text.length)); + } + } + + /// Transition from the suggestions returned by [buildSuggestions] to the + /// [query] results returned by [buildResults]. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] the + /// screen should typically transition to the page showing the search + /// results for the suggested query. This transition can be triggered + /// by calling this method. + /// + /// See also: + /// + /// * [showSuggestions] to show the search suggestions again. + void showResults(BuildContext context) { + _focusNode?.unfocus(); + _currentBody = _SearchBody.results; + } + + /// Transition from showing the results returned by [buildResults] to showing + /// the suggestions returned by [buildSuggestions]. + /// + /// Calling this method will also put the input focus back into the search + /// field of the [AppBar]. + /// + /// If the results are currently shown this method can be used to go back + /// to showing the search suggestions. + /// + /// See also: + /// + /// * [showResults] to show the search results. + void showSuggestions(BuildContext context) { + assert(_focusNode != null, + '_focusNode must be set by route before showSuggestions is called.'); + _focusNode!.requestFocus(); + _currentBody = _SearchBody.suggestions; + } + + /// Closes the search page and returns to the underlying route. + /// + /// The value provided for `result` is used as the return value of the call + /// to [showMaterial3Search] that launched the search initially. + void close(BuildContext context, T result) { + _currentBody = null; + _focusNode?.unfocus(); + Navigator.of(context) + ..popUntil((Route route) => route == _route) + ..pop(result); + } + + /// The hint text that is shown in the search field when it is empty. + /// + /// If this value is set to null, the value of + /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead. + final String? searchFieldLabel; + + /// The style of the [searchFieldLabel]. + /// + /// If this value is set to null, the value of the ambient [Theme]'s + /// [InputDecorationTheme.hintStyle] will be used instead. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final TextStyle? searchFieldStyle; + + /// The [InputDecorationTheme] used to configure the search field's visuals. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final InputDecorationTheme? searchFieldDecorationTheme; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to the default value specified in [TextField]. + final TextInputType? keyboardType; + + /// The text input action configuring the soft keyboard to a particular action + /// button. + /// + /// Defaults to [TextInputAction.search]. + final TextInputAction textInputAction; + + /// [Animation] triggered when the search pages fades in or out. + /// + /// This animation is commonly used to animate [AnimatedIcon]s of + /// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be + /// used to animate [IconButton]s contained within the route below the search + /// page. + Animation get transitionAnimation => _proxyAnimation; + + // The focus node to use for manipulating focus on the search page. This is + // managed, owned, and set by the _SearchPageRoute using this delegate. + FocusNode? _focusNode; + + final TextEditingController _queryTextController = TextEditingController(); + + final ProxyAnimation _proxyAnimation = + ProxyAnimation(kAlwaysDismissedAnimation); + + final ValueNotifier<_SearchBody?> _currentBodyNotifier = + ValueNotifier<_SearchBody?>(null); + + _SearchBody? get _currentBody => _currentBodyNotifier.value; + set _currentBody(_SearchBody? value) { + _currentBodyNotifier.value = value; + } + + _SearchPageRoute? _route; +} + +/// Describes the body that is currently shown under the [AppBar] in the +/// search page. +enum _SearchBody { + /// Suggested queries are shown in the body. + /// + /// The suggested queries are generated by [SearchDelegate.buildSuggestions]. + suggestions, + + /// Search results are currently shown in the body. + /// + /// The search results are generated by [SearchDelegate.buildResults]. + results, +} + +class _SearchPageRoute extends PageRoute { + _SearchPageRoute({ + required this.delegate, + }) { + assert( + delegate._route == null, + 'The ${delegate.runtimeType} instance is currently used by another active ' + 'search. Please close that search by calling close() on the SearchDelegate ' + 'before opening another search with the same delegate instance.', + ); + delegate._route = this; + } + + final SearchDelegate delegate; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => false; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: child, + ); + } + + @override + Animation createAnimation() { + final Animation animation = super.createAnimation(); + delegate._proxyAnimation.parent = animation; + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return _SearchPage( + delegate: delegate, + animation: animation, + ); + } + + @override + void didComplete(T? result) { + super.didComplete(result); + assert(delegate._route == this); + delegate._route = null; + delegate._currentBody = null; + } +} + +class _SearchPage extends StatefulWidget { + const _SearchPage({ + required this.delegate, + required this.animation, + }); + + final SearchDelegate delegate; + final Animation animation; + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State<_SearchPage> { + // This node is owned, but not hosted by, the search page. Hosting is done by + // the text field. + FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + widget.delegate._queryTextController.addListener(_onQueryChanged); + widget.animation.addStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + focusNode.addListener(_onFocusChanged); + widget.delegate._focusNode = focusNode; + } + + @override + void dispose() { + super.dispose(); + widget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.animation.removeStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate._focusNode = null; + focusNode.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (status != AnimationStatus.completed) { + return; + } + widget.animation.removeStatusListener(_onAnimationStatusChanged); + if (widget.delegate._currentBody == _SearchBody.suggestions) { + focusNode.requestFocus(); + } + } + + @override + void didUpdateWidget(_SearchPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + oldWidget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.delegate._queryTextController.addListener(_onQueryChanged); + oldWidget.delegate._currentBodyNotifier + .removeListener(_onSearchBodyChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + oldWidget.delegate._focusNode = null; + widget.delegate._focusNode = focusNode; + } + } + + void _onFocusChanged() { + if (focusNode.hasFocus && + widget.delegate._currentBody != _SearchBody.suggestions) { + widget.delegate.showSuggestions(context); + } + } + + void _onQueryChanged() { + setState(() { + // rebuild ourselves because query changed. + }); + } + + void _onSearchBodyChanged() { + setState(() { + // rebuild ourselves because search body changed. + }); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = widget.delegate.appBarTheme(context); + final String searchFieldLabel = widget.delegate.searchFieldLabel ?? + MaterialLocalizations.of(context).searchFieldLabel; + Widget? body; + switch (widget.delegate._currentBody) { + case _SearchBody.suggestions: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.suggestions), + child: widget.delegate.buildSuggestions(context), + ); + break; + case _SearchBody.results: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.results), + child: widget.delegate.buildResults(context), + ); + break; + case null: + break; + } + + late final String routeName; + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + routeName = ''; + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + routeName = searchFieldLabel; + } + + return Semantics( + explicitChildNodes: true, + scopesRoute: true, + namesRoute: true, + label: routeName, + child: Theme( + data: theme, + child: Scaffold( + appBar: AppBar( + toolbarHeight: 72, + leading: widget.delegate.buildLeading(context), + title: TextField( + controller: widget.delegate._queryTextController, + focusNode: focusNode, + style: widget.delegate.searchFieldStyle ?? + theme.textTheme.titleLarge, + textInputAction: widget.delegate.textInputAction, + keyboardType: widget.delegate.keyboardType, + onSubmitted: (String _) { + widget.delegate.showResults(context); + }, + decoration: InputDecoration(hintText: searchFieldLabel), + ), + actions: widget.delegate.buildActions(context), + bottom: widget.delegate.buildBottom(context), + ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: body, + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/material/search/m3_search_bar.dart b/lib/core/widgets/material/search/m3_search_bar.dart new file mode 100644 index 0000000..dafcea1 --- /dev/null +++ b/lib/core/widgets/material/search/m3_search_bar.dart @@ -0,0 +1,79 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class SearchBar extends StatelessWidget { + const SearchBar({ + Key? key, + this.height = 56, + required this.leadingIcon, + this.trailingIcon, + required this.supportingText, + required this.onTap, + }) : super(key: key); + + final double height; + double get effectiveHeight { + return max(height, 48); + } + + final VoidCallback onTap; + final Widget leadingIcon; + final Widget? trailingIcon; + + final String supportingText; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextTheme textTheme = Theme.of(context).textTheme; + + return Container( + constraints: const BoxConstraints(minWidth: 360, maxWidth: 720), + width: double.infinity, + height: effectiveHeight, + child: Material( + elevation: 1, + color: colorScheme.surface, + shadowColor: colorScheme.shadow, + surfaceTintColor: colorScheme.surfaceTint, + borderRadius: BorderRadius.circular(effectiveHeight / 2), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(effectiveHeight / 2), + highlightColor: Colors.transparent, + splashFactory: InkRipple.splashFactory, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(children: [ + leadingIcon, + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: TextField( + onTap: onTap, + readOnly: true, + enabled: false, + cursorColor: colorScheme.primary, + style: textTheme.bodyLarge, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + hintText: supportingText, + hintStyle: textTheme.bodyLarge?.apply( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + if (trailingIcon != null) trailingIcon!, + ]), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/paperless_logo.dart b/lib/core/widgets/paperless_logo.dart index 1f5b08b..38e3b0e 100644 --- a/lib/core/widgets/paperless_logo.dart +++ b/lib/core/widgets/paperless_logo.dart @@ -2,18 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; class PaperlessLogo extends StatelessWidget { + static const _paperlessGreen = Color(0xFF18541F); final double? height; final double? width; - final String _path; + final Color _color; - const PaperlessLogo.white({super.key, this.height, this.width}) - : _path = "assets/logos/paperless_logo_white.svg"; + const PaperlessLogo.white({ + super.key, + this.height, + this.width, + }) : _color = Colors.white; const PaperlessLogo.green({super.key, this.height, this.width}) - : _path = "assets/logos/paperless_logo_green.svg"; + : _color = _paperlessGreen; const PaperlessLogo.black({super.key, this.height, this.width}) - : _path = "assets/logos/paperless_logo_black.svg"; + : _color = Colors.black; + + const PaperlessLogo.colored(Color color, {super.key, this.height, this.width}) + : _color = color; @override Widget build(BuildContext context) { @@ -24,7 +31,8 @@ class PaperlessLogo extends StatelessWidget { ), padding: const EdgeInsets.only(right: 8), child: SvgPicture.asset( - _path, + "assets/logos/paperless_logo_white.svg", + color: _color, ), ); } diff --git a/lib/core/widgets/shimmer_placeholder.dart b/lib/core/widgets/shimmer_placeholder.dart new file mode 100644 index 0000000..d3b41be --- /dev/null +++ b/lib/core/widgets/shimmer_placeholder.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class ShimmerPlaceholder extends StatelessWidget { + final Widget child; + + const ShimmerPlaceholder({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[300]! + : Colors.grey[900]!, + highlightColor: Theme.of(context).brightness == Brightness.light + ? Colors.grey[100]! + : Colors.grey[600]!, + child: child, + ); + } +} diff --git a/lib/extensions/date_time_extensions.dart b/lib/extensions/date_time_extensions.dart deleted file mode 100644 index 657c071..0000000 --- a/lib/extensions/date_time_extensions.dart +++ /dev/null @@ -1,5 +0,0 @@ -extension DateComparisons on DateTime { - bool isEqualToIgnoringDate(DateTime other) { - return day == other.day && month == other.month && year == other.year; - } -} diff --git a/lib/extensions/hydrated_storage_extension.dart b/lib/extensions/hydrated_storage_extension.dart index ef241e1..96ba2da 100644 --- a/lib/extensions/hydrated_storage_extension.dart +++ b/lib/extensions/hydrated_storage_extension.dart @@ -1,7 +1,6 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_state.dart'; -import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; extension AddressableHydratedStorage on Storage { ApplicationSettingsState get settings { diff --git a/lib/extensions/security_context_extension.dart b/lib/extensions/security_context_extension.dart deleted file mode 100644 index 26e7789..0000000 --- a/lib/extensions/security_context_extension.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:paperless_mobile/features/login/model/client_certificate.dart'; - -extension ClientCertificateHandlingSecurityContext on SecurityContext { - SecurityContext withClientCertificate(ClientCertificate? clientCertificate) { - if (clientCertificate == null) return this; - return this - ..usePrivateKeyBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..useCertificateChainBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..setTrustedCertificatesBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ); - } -} diff --git a/lib/extensions/string_extensions.dart b/lib/extensions/string_extensions.dart deleted file mode 100644 index 9e5a9b6..0000000 --- a/lib/extensions/string_extensions.dart +++ /dev/null @@ -1,7 +0,0 @@ -extension SizeLimitedString on String { - String withLengthLimitedTo(int length, [String overflow = "..."]) { - return this.length > length - ? '${substring(0, length - overflow.length)}$overflow' - : this; - } -} diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart new file mode 100644 index 0000000..0aae097 --- /dev/null +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/settings/bloc/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/view/settings_page.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:url_launcher/link.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class AppDrawer extends StatelessWidget { + const AppDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + top: true, + child: Drawer( + child: Column( + children: [ + Row( + children: [ + const PaperlessLogo.green(), + Text( + "Paperless Mobile", + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ).padded(), + const Divider(), + ListTile( + dense: true, + title: Text(S.of(context).appDrawerAboutLabel), + leading: const Icon(Icons.info_outline), + onTap: () => _showAboutDialog(context), + ), + ListTile( + dense: true, + leading: const Icon(Icons.bug_report_outlined), + title: Text(S.of(context).appDrawerReportBugLabel), + onTap: () { + launchUrlString( + 'https://github.com/astubenbord/paperless-mobile/issues/new'); + }, + ), + ListTile( + dense: true, + leading: const Icon(Icons.settings_outlined), + title: Text( + S.of(context).appDrawerSettingsLabel, + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: const SettingsPage(), + ), + ), + ), + ), + ], + ), + ), + ); + } + + 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).aboutDialogDevelopedByText('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.') + ], + ), + ); + } +} diff --git a/lib/features/app_intro/widgets/welcome_intro_slide.dart b/lib/features/app_intro/widgets/welcome_intro_slide.dart deleted file mode 100644 index a1fc625..0000000 --- a/lib/features/app_intro/widgets/welcome_intro_slide.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -class WelcomeIntroSlide extends StatelessWidget { - const WelcomeIntroSlide({super.key}); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Text( - "Welcome to Paperless Mobile!", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, - ), - Padding( - padding: const EdgeInsets.all(16), - child: Text( - "Manage, share and create documents on the go without any compromises!", - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).hintColor), - ), - ), - Align(child: Image.asset("assets/logos/paperless_logo_green.png")), - ], - ); - } -} diff --git a/lib/features/document_details/bloc/document_details_cubit.dart b/lib/features/document_details/bloc/document_details_cubit.dart index 7f504bd..eaed8d2 100644 --- a/lib/features/document_details/bloc/document_details_cubit.dart +++ b/lib/features/document_details/bloc/document_details_cubit.dart @@ -1,26 +1,32 @@ -import 'dart:developer'; +import 'dart:async'; import 'dart:io'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:url_launcher/url_launcher_string.dart'; 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/service/file_service.dart'; part 'document_details_state.dart'; class DocumentDetailsCubit extends Cubit { final PaperlessDocumentsApi _api; + final DocumentChangedNotifier _notifier; - DocumentDetailsCubit(this._api, DocumentModel initialDocument) - : super(DocumentDetailsState(document: initialDocument)) { + final List _subscriptions = []; + DocumentDetailsCubit( + this._api, + this._notifier, { + required DocumentModel initialDocument, + }) : super(DocumentDetailsState(document: initialDocument)) { + _notifier.subscribe(this, onUpdated: replace); loadSuggestions(); } Future delete(DocumentModel document) async { await _api.delete(document); + _notifier.notifyDeleted(document); } Future loadSuggestions() async { @@ -44,21 +50,35 @@ class DocumentDetailsCubit extends Cubit { final int asn = await _api.findNextAsn(); final updatedDocument = await _api.update(document.copyWith(archiveSerialNumber: asn)); - emit(state.copyWith(document: updatedDocument)); + _notifier.notifyUpdated(updatedDocument); } } Future openDocumentInSystemViewer() async { - final downloadDir = await FileService.temporaryDirectory; + final cacheDir = await FileService.temporaryDirectory; + final metaData = await _api.getMetaData(state.document); - final docBytes = await _api.download(state.document); - File f = File('${downloadDir.path}/${metaData.mediaFilename}'); - f.writeAsBytes(docBytes); - return OpenFilex.open(f.path, type: "application/pdf") - .then((value) => value.type); + final bytes = await _api.download(state.document); + + final file = File('${cacheDir.path}/${metaData.mediaFilename}') + ..createSync(recursive: true) + ..writeAsBytesSync(bytes); + + return OpenFilex.open(file.path, type: "application/pdf").then( + (value) => value.type, + ); } - void replaceDocument(DocumentModel document) { + void replace(DocumentModel document) { emit(state.copyWith(document: document)); } + + @override + Future close() { + for (final element in _subscriptions) { + element.cancel(); + } + _notifier.unsubscribe(this); + return super.close(); + } } 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 17270e5..648d70a 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'dart:math'; +import 'package:badges/badges.dart' as b; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -8,7 +8,6 @@ import 'package:intl/intl.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; @@ -23,14 +22,15 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi import 'package:paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; +import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/format_helpers.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:badges/badges.dart' as b; - -import '../../../../core/repository/state/impl/document_type_repository_state.dart'; +import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; +//TODO: Refactor this into several widgets class DocumentDetailsPage extends StatefulWidget { final bool allowEdit; final bool isLabelClickable; @@ -48,6 +48,16 @@ class DocumentDetailsPage extends StatefulWidget { } class _DocumentDetailsPageState extends State { + late Future _metaData; + + @override + void initState() { + super.initState(); + _metaData = context + .read() + .getMetaData(context.read().state.document); + } + @override Widget build(BuildContext context) { return WillPopScope( @@ -57,115 +67,15 @@ class _DocumentDetailsPageState extends State { return false; }, child: DefaultTabController( - length: 3, + length: 4, child: Scaffold( floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - floatingActionButton: widget.allowEdit - ? BlocBuilder( - builder: (context, state) { - final _filteredSuggestions = - state.suggestions.documentDifference(state.document); - return BlocBuilder( - builder: (context, connectivityState) { - if (!connectivityState.isConnected) { - return Container(); - } - return b.Badge( - position: b.BadgePosition.topEnd(top: -12, end: -6), - showBadge: _filteredSuggestions.hasSuggestions, - child: Tooltip( - message: - S.of(context).documentDetailsPageEditTooltip, - preferBelow: false, - verticalOffset: 40, - child: FloatingActionButton( - child: const Icon(Icons.edit), - onPressed: () => _onEdit(state.document), - ), - ), - badgeContent: Text( - '${_filteredSuggestions.suggestionsCount}', - style: const TextStyle( - color: Colors.white, - ), - ), - badgeColor: Colors.red, - ); - }, - ); - }, - ) - : null, - bottomNavigationBar: - BlocBuilder( - builder: (context, state) { - return BottomAppBar( - child: BlocBuilder( - builder: (context, connectivityState) { - final isConnected = connectivityState.isConnected; - return Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - tooltip: - S.of(context).documentDetailsPageDeleteTooltip, - icon: const Icon(Icons.delete), - onPressed: widget.allowEdit && isConnected - ? () => _onDelete(state.document) - : null, - ).paddedSymmetrically(horizontal: 4), - Tooltip( - message: - S.of(context).documentDetailsPageDownloadTooltip, - child: DocumentDownloadButton( - document: state.document, - enabled: isConnected, - ), - ), - IconButton( - tooltip: - S.of(context).documentDetailsPagePreviewTooltip, - icon: const Icon(Icons.visibility), - onPressed: isConnected - ? () => _onOpen(state.document) - : null, - ).paddedOnly(right: 4.0), - IconButton( - tooltip: S - .of(context) - .documentDetailsPageOpenInSystemViewerTooltip, - icon: const Icon(Icons.open_in_new), - onPressed: - isConnected ? _onOpenFileInSystemViewer : null, - ).paddedOnly(right: 4.0), - IconButton( - tooltip: - S.of(context).documentDetailsPageShareTooltip, - icon: const Icon(Icons.share), - onPressed: isConnected - ? () => _onShare(state.document) - : null, - ), - ], - ); - }, - ), - ); - }, - ), + floatingActionButton: widget.allowEdit ? _buildAppBar() : null, + bottomNavigationBar: _buildBottomAppBar(), body: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverAppBar( - leading: IconButton( - icon: const Icon( - Icons.arrow_back, - color: Colors - .black, //TODO: check if there is a way to dynamically determine color... - ), - onPressed: () => Navigator.of(context).pop( - context.read().state.document, - ), - ), + leading: const BackButton(), floating: true, pinned: true, expandedHeight: 200.0, @@ -180,6 +90,7 @@ class _DocumentDetailsPageState extends State { backgroundColor: Theme.of(context).colorScheme.primaryContainer, tabBar: TabBar( + isScrollable: true, tabs: [ Tab( child: Text( @@ -208,6 +119,18 @@ class _DocumentDetailsPageState extends State { .onPrimaryContainer), ), ), + Tab( + child: Text( + S + .of(context) + .documentDetailsPageTabSimilarDocumentsLabel, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), ], ), ), @@ -215,19 +138,27 @@ class _DocumentDetailsPageState extends State { ], body: BlocBuilder( builder: (context, state) { - return TabBarView( - children: [ - _buildDocumentOverview( - state.document, - ), - _buildDocumentContentView( - state.document, - state, - ), - _buildDocumentMetaDataView( - state.document, - ), - ], + return BlocProvider( + create: (context) => SimilarDocumentsCubit( + context.read(), + context.read(), + documentId: state.document.id, + ), + child: TabBarView( + children: [ + _buildDocumentOverview( + state.document, + ), + _buildDocumentContentView( + state.document, + state, + ), + _buildDocumentMetaDataView( + state.document, + ), + const SimilarDocumentsView(), + ], + ), ).paddedSymmetrically(horizontal: 8); }, ), @@ -237,6 +168,94 @@ class _DocumentDetailsPageState extends State { ); } + BlocBuilder _buildAppBar() { + return BlocBuilder( + builder: (context, state) { + final _filteredSuggestions = + state.suggestions.documentDifference(state.document); + return BlocBuilder( + builder: (context, connectivityState) { + if (!connectivityState.isConnected) { + return Container(); + } + return b.Badge( + position: b.BadgePosition.topEnd(top: -12, end: -6), + showBadge: _filteredSuggestions.hasSuggestions, + child: Tooltip( + message: S.of(context).documentDetailsPageEditTooltip, + preferBelow: false, + verticalOffset: 40, + child: FloatingActionButton( + child: const Icon(Icons.edit), + onPressed: () => _onEdit(state.document), + ), + ), + badgeContent: Text( + '${_filteredSuggestions.suggestionsCount}', + style: const TextStyle( + color: Colors.white, + ), + ), + badgeColor: Colors.red, + ); + }, + ); + }, + ); + } + + BlocBuilder _buildBottomAppBar() { + return BlocBuilder( + builder: (context, state) { + return BottomAppBar( + child: BlocBuilder( + builder: (context, connectivityState) { + final isConnected = connectivityState.isConnected; + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + tooltip: S.of(context).documentDetailsPageDeleteTooltip, + icon: const Icon(Icons.delete), + onPressed: widget.allowEdit && isConnected + ? () => _onDelete(state.document) + : null, + ).paddedSymmetrically(horizontal: 4), + Tooltip( + message: S.of(context).documentDetailsPageDownloadTooltip, + child: DocumentDownloadButton( + document: state.document, + enabled: isConnected, + ), + ), + IconButton( + tooltip: S.of(context).documentDetailsPagePreviewTooltip, + icon: const Icon(Icons.visibility), + onPressed: + isConnected ? () => _onOpen(state.document) : null, + ).paddedOnly(right: 4.0), + IconButton( + tooltip: S + .of(context) + .documentDetailsPageOpenInSystemViewerTooltip, + icon: const Icon(Icons.open_in_new), + onPressed: isConnected ? _onOpenFileInSystemViewer : null, + ).paddedOnly(right: 4.0), + IconButton( + tooltip: S.of(context).documentDetailsPageShareTooltip, + icon: const Icon(Icons.share), + onPressed: + isConnected ? () => _onShare(state.document) : null, + ), + ], + ); + }, + ), + ); + }, + ); + } + Future _onEdit(DocumentModel document) async { { final cubit = context.read(); @@ -253,6 +272,7 @@ class _DocumentDetailsPageState extends State { documentTypeRepository: context.read(), storagePathRepository: context.read(), tagRepository: context.read(), + notifier: context.read(), ), ), BlocProvider.value( @@ -263,7 +283,7 @@ class _DocumentDetailsPageState extends State { listenWhen: (previous, current) => previous.document != current.document, listener: (context, state) { - cubit.replaceDocument(state.document); + cubit.replace(state.document); }, child: BlocBuilder( builder: (context, state) { @@ -306,7 +326,7 @@ class _DocumentDetailsPageState extends State { ); } return FutureBuilder( - future: context.read().getMetaData(document), + future: _metaData, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); @@ -430,7 +450,7 @@ class _DocumentDetailsPageState extends State { visible: document.documentType != null, child: _DetailsItem( label: S.of(context).documentDocumentTypePropertyLabel, - content: LabelText( + content: LabelText( style: Theme.of(context).textTheme.bodyLarge, id: document.documentType, ), @@ -440,7 +460,7 @@ class _DocumentDetailsPageState extends State { visible: document.correspondent != null, child: _DetailsItem( label: S.of(context).documentCorrespondentPropertyLabel, - content: LabelText( + content: LabelText( style: Theme.of(context).textTheme.bodyLarge, id: document.correspondent, ), @@ -451,7 +471,6 @@ class _DocumentDetailsPageState extends State { child: _DetailsItem( label: S.of(context).documentStoragePathPropertyLabel, content: StoragePathWidget( - isClickable: widget.isLabelClickable, pathId: document.storagePath, ), ).paddedSymmetrically(vertical: 16), @@ -465,34 +484,10 @@ class _DocumentDetailsPageState extends State { child: TagsWidget( isClickable: widget.isLabelClickable, tagIds: document.tags, - onTagSelected: (int tagId) {}, ), ), ).paddedSymmetrically(vertical: 16), ), - // _separator(), - // FutureBuilder>( - // future: getIt().findSimilar(document.id), - // builder: (context, snapshot) { - // if (!snapshot.hasData) { - // return CircularProgressIndicator(); - // } - // return ExpansionTile( - // tilePadding: const EdgeInsets.symmetric(horizontal: 8.0), - // title: Text( - // S.of(context).documentDetailsPageSimilarDocumentsLabel, - // style: - // Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold), - // ), - // children: snapshot.data! - // .map((e) => DocumentListItem( - // document: e, - // onTap: (doc) {}, - // isSelected: false, - // isAtLeastOneSelected: false)) - // .toList(), - // ); - // }), ], ); } @@ -549,15 +544,6 @@ class _DocumentDetailsPageState extends State { ), ); } - - static String formatBytes(int bytes, int decimals) { - if (bytes <= 0) return "0 B"; - const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - var i = (log(bytes) / log(1024)).floor(); - return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + - ' ' + - suffixes[i]; - } } class _DetailsItem extends StatelessWidget { 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 8e91de1..ccaf894 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -5,7 +5,8 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/constants.dart'; import 'package:provider/provider.dart'; class DocumentDownloadButton extends StatefulWidget { @@ -47,20 +48,24 @@ class _DocumentDownloadButtonState extends State { return; } setState(() => _isDownloadPending = true); + final service = context.read(); try { - final bytes = - await context.read().download(document); + final bytes = await service.download(document); + final meta = await service.getMetaData(document); final Directory dir = await FileService.downloadsDirectory; - String filePath = "${dir.path}/${document.originalFileName}"; - //TODO: Add replacement mechanism here (ask user if file should be replaced if exists) - await File(filePath).writeAsBytes(bytes); + String filePath = "${dir.path}/${meta.mediaFilename}"; + final createdFile = File(filePath); + createdFile.createSync(recursive: true); + createdFile.writeAsBytesSync(bytes); showSnackBar(context, S.of(context).documentDownloadSuccessMessage); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } catch (error) { showGenericError(context, error); } finally { - setState(() => _isDownloadPending = false); + if (mounted) { + setState(() => _isDownloadPending = false); + } } } } diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart new file mode 100644 index 0000000..616d46f --- /dev/null +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -0,0 +1,85 @@ +import 'package:collection/collection.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; + +class DocumentSearchCubit extends HydratedCubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; + @override + final DocumentChangedNotifier notifier; + + DocumentSearchCubit(this.api, this.notifier) + : super(const DocumentSearchState()) { + notifier.subscribe( + this, + onDeleted: remove, + onUpdated: replace, + ); + } + + Future search(String query) async { + emit(state.copyWith( + isLoading: true, + suggestions: [], + view: SearchView.results, + )); + final searchFilter = DocumentFilter( + query: TextQuery.titleAndContent(query), + ); + + await updateFilter(filter: searchFilter); + emit( + state.copyWith( + searchHistory: [ + query, + ...state.searchHistory + .whereNot((previousQuery) => previousQuery == query) + ], + ), + ); + } + + Future suggest(String query) async { + emit( + state.copyWith( + isLoading: true, + view: SearchView.suggestions, + value: [], + suggestions: [], + ), + ); + final suggestions = await api.autocomplete(query); + emit(state.copyWith( + suggestions: suggestions, + isLoading: false, + )); + } + + void reset() { + emit(state.copyWith( + view: SearchView.suggestions, + suggestions: [], + isLoading: false, + )); + } + + @override + Future close() { + notifier.unsubscribe(this); + return super.close(); + } + + @override + DocumentSearchState? fromJson(Map json) { + return DocumentSearchState.fromJson(json); + } + + @override + Map? toJson(DocumentSearchState state) { + return state.toJson(); + } +} diff --git a/lib/features/document_search/cubit/document_search_state.dart b/lib/features/document_search/cubit/document_search_state.dart new file mode 100644 index 0000000..231405d --- /dev/null +++ b/lib/features/document_search/cubit/document_search_state.dart @@ -0,0 +1,76 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; + +part 'document_search_state.g.dart'; + +enum SearchView { + suggestions, + results; +} + +@JsonSerializable(ignoreUnannotated: true) +class DocumentSearchState extends PagedDocumentsState { + @JsonKey() + final List searchHistory; + final SearchView view; + final List suggestions; + const DocumentSearchState({ + this.view = SearchView.suggestions, + this.searchHistory = const [], + this.suggestions = const [], + super.filter, + super.hasLoaded, + super.isLoading, + super.value, + }); + + @override + List get props => [ + ...super.props, + searchHistory, + suggestions, + view, + ]; + + @override + DocumentSearchState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + filter: filter, + value: value, + ); + } + + DocumentSearchState copyWith({ + List? searchHistory, + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + List? suggestions, + SearchView? view, + }) { + return DocumentSearchState( + value: value ?? this.value, + filter: filter ?? this.filter, + hasLoaded: hasLoaded ?? this.hasLoaded, + isLoading: isLoading ?? this.isLoading, + searchHistory: searchHistory ?? this.searchHistory, + view: view ?? this.view, + suggestions: suggestions ?? this.suggestions, + ); + } + + factory DocumentSearchState.fromJson(Map json) => + _$DocumentSearchStateFromJson(json); + + Map toJson() => _$DocumentSearchStateToJson(this); +} diff --git a/lib/features/document_search/cubit/document_search_state.g.dart b/lib/features/document_search/cubit/document_search_state.g.dart new file mode 100644 index 0000000..6d1bb68 --- /dev/null +++ b/lib/features/document_search/cubit/document_search_state.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'document_search_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DocumentSearchState _$DocumentSearchStateFromJson(Map json) => + DocumentSearchState( + searchHistory: (json['searchHistory'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + +Map _$DocumentSearchStateToJson( + DocumentSearchState instance) => + { + 'searchHistory': instance.searchHistory, + }; diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart new file mode 100644 index 0000000..ed2ee3b --- /dev/null +++ b/lib/features/document_search/view/document_search_page.dart @@ -0,0 +1,182 @@ +import 'package:collection/collection.dart'; +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/document_search/cubit/document_search_cubit.dart'; +import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; + +Future showDocumentSearchPage(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => DocumentSearchCubit( + context.read(), + context.read(), + ), + child: const DocumentSearchPage(), + ), + ), + ); +} + +class DocumentSearchPage extends StatefulWidget { + const DocumentSearchPage({super.key}); + + @override + State createState() => _DocumentSearchPageState(); +} + +class _DocumentSearchPageState extends State { + final _queryController = TextEditingController(text: ''); + + String get query => _queryController.text; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + backgroundColor: theme.colorScheme.surface, + toolbarHeight: 72, + leading: BackButton( + color: theme.colorScheme.onSurface, + ), + title: TextField( + autofocus: true, + style: theme.textTheme.bodyLarge?.apply( + color: theme.colorScheme.onSurface, + ), + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + hintStyle: theme.textTheme.bodyLarge?.apply( + color: theme.colorScheme.onSurfaceVariant, + ), + hintText: S.of(context).documentSearchSearchDocuments, + border: InputBorder.none, + ), + controller: _queryController, + onChanged: context.read().suggest, + textInputAction: TextInputAction.search, + onSubmitted: (query) { + FocusScope.of(context).unfocus(); + context.read().search(query); + }, + ), + actions: [ + IconButton( + color: theme.colorScheme.onSurfaceVariant, + icon: const Icon(Icons.clear), + onPressed: () { + context.read().reset(); + _queryController.clear(); + }, + ).padded(), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Divider( + color: theme.colorScheme.outline, + ), + ), + ), + body: BlocBuilder( + builder: (context, state) { + switch (state.view) { + case SearchView.suggestions: + return _buildSuggestionsView(state); + case SearchView.results: + return _buildResultsView(state); + } + }, + ), + ); + } + + Widget _buildSuggestionsView(DocumentSearchState state) { + final suggestions = state.suggestions + .whereNot((element) => state.searchHistory.contains(element)) + .toList(); + final historyMatches = state.searchHistory + .where( + (element) => element.startsWith(query), + ) + .toList(); + return CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile( + title: Text(historyMatches[index]), + leading: const Icon(Icons.history), + onTap: () => _selectSuggestion(historyMatches[index]), + ), + childCount: historyMatches.length, + ), + ), + if (state.isLoading) + const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile( + title: Text(suggestions[index]), + leading: const Icon(Icons.search), + onTap: () => _selectSuggestion(suggestions[index]), + ), + childCount: suggestions.length, + ), + ) + ], + ); + } + + Widget _buildResultsView(DocumentSearchState state) { + final header = Text( + S.of(context).documentSearchResults, + style: Theme.of(context).textTheme.labelSmall, + ).padded(); + return CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: header), + if (state.hasLoaded && !state.isLoading && state.documents.isEmpty) + SliverToBoxAdapter( + child: Center( + child: Text(S.of(context).documentSearchNoMatchesFound), + ), + ) + else + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: true, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + enableHeroAnimation: false, + onTap: (document) { + Navigator.pushNamed( + context, + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: document, + isLabelClickable: false, + ), + ); + }, + ) + ], + ); + } + + void _selectSuggestion(String suggestion) { + _queryController.text = suggestion; + context.read().search(suggestion); + FocusScope.of(context).unfocus(); + } +} diff --git a/lib/features/labels/tags/view/widgets/form_builder_tag_selection_field.dart b/lib/features/document_search/view/documents_search_app_bar.dart similarity index 100% rename from lib/features/labels/tags/view/widgets/form_builder_tag_selection_field.dart rename to lib/features/document_search/view/documents_search_app_bar.dart diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index fcc159c..3ec4326 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -8,29 +8,23 @@ import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; part 'document_upload_state.dart'; class DocumentUploadCubit extends Cubit { final PaperlessDocumentsApi _documentApi; - final LabelRepository _tagRepository; - final LabelRepository - _correspondentRepository; - final LabelRepository - _documentTypeRepository; + final LabelRepository _tagRepository; + final LabelRepository _correspondentRepository; + final LabelRepository _documentTypeRepository; final List _subs = []; DocumentUploadCubit({ - required LocalVault localVault, required PaperlessDocumentsApi documentApi, - required LabelRepository tagRepository, - required LabelRepository - correspondentRepository, - required LabelRepository - documentTypeRepository, + required LabelRepository tagRepository, + required LabelRepository correspondentRepository, + required LabelRepository documentTypeRepository, }) : _documentApi = documentApi, _tagRepository = tagRepository, _correspondentRepository = correspondentRepository, 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 34974ac..49dca21 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -8,10 +8,7 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/type/types.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.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,7 +16,7 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type 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.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; class DocumentUploadPreparationPage extends StatefulWidget { final Uint8List fileBytes; @@ -172,9 +169,8 @@ class _DocumentUploadPreparationPageState formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (initialName) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => + context.read>(), child: AddDocumentTypePage(initialName: initialName), ), textFieldLabel: @@ -188,9 +184,8 @@ class _DocumentUploadPreparationPageState formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (initialName) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => + context.read>(), child: AddCorrespondentPage(initialName: initialName), ), textFieldLabel: diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart index 1eb3e32..b251a0f 100644 --- a/lib/features/documents/bloc/documents_cubit.dart +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -1,27 +1,37 @@ import 'dart:async'; import 'dart:developer'; +import 'package:flutter/foundation.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; +import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; class DocumentsCubit extends HydratedCubit - with DocumentsPagingMixin { + with PagedDocumentsMixin { @override final PaperlessDocumentsApi api; - final SavedViewRepository _savedViewRepository; + @override + final DocumentChangedNotifier notifier; - DocumentsCubit(this.api, this._savedViewRepository) - : super(const DocumentsState()); + DocumentsCubit(this.api, this.notifier) : super(const DocumentsState()) { + notifier.subscribe( + this, + onUpdated: replace, + onDeleted: remove, + ); + } - Future bulkRemove(List documents) async { - log("[DocumentsCubit] bulkRemove"); + Future bulkDelete(List documents) async { + debugPrint("[DocumentsCubit] bulkRemove"); await api.bulkAction( BulkDeleteAction(documents.map((doc) => doc.id)), ); + for (final deletedDoc in documents) { + notifier.notifyDeleted(deletedDoc); + } await reload(); } @@ -30,7 +40,7 @@ class DocumentsCubit extends HydratedCubit Iterable addTags = const [], Iterable removeTags = const [], }) async { - log("[DocumentsCubit] bulkEditTags"); + debugPrint("[DocumentsCubit] bulkEditTags"); await api.bulkAction(BulkModifyTagsAction( documents.map((doc) => doc.id), addTags: addTags, @@ -40,7 +50,7 @@ class DocumentsCubit extends HydratedCubit } void toggleDocumentSelection(DocumentModel model) { - log("[DocumentsCubit] toggleSelection"); + debugPrint("[DocumentsCubit] toggleSelection"); if (state.selectedIds.contains(model.id)) { emit( state.copyWith( @@ -50,54 +60,25 @@ class DocumentsCubit extends HydratedCubit ), ); } else { - emit( - state.copyWith(selection: [...state.selection, model]), - ); + emit(state.copyWith(selection: [...state.selection, model])); } } void resetSelection() { - log("[DocumentsCubit] resetSelection"); + debugPrint("[DocumentsCubit] resetSelection"); emit(state.copyWith(selection: [])); } void reset() { - log("[DocumentsCubit] reset"); + debugPrint("[DocumentsCubit] reset"); emit(const DocumentsState()); } - Future selectView(int id) async { - emit(state.copyWith(isLoading: true)); - try { - final filter = - _savedViewRepository.current?.values[id]?.toDocumentFilter(); - if (filter == null) { - return; - } - final results = await api.findAll(filter.copyWith(page: 1)); - emit( - DocumentsState( - filter: filter, - hasLoaded: true, - isLoading: false, - selectedSavedViewId: id, - value: [results], - ), - ); - } finally { - emit(state.copyWith(isLoading: false)); - } - } - Future> autocomplete(String query) async { final res = await api.autocomplete(query); return res; } - void unselectView() { - emit(state.copyWith(selectedSavedViewId: () => null)); - } - @override DocumentsState? fromJson(Map json) { return DocumentsState.fromJson(json); @@ -107,4 +88,10 @@ class DocumentsCubit extends HydratedCubit Map? toJson(DocumentsState state) { return state.toJson(); } + + @override + Future close() { + notifier.unsubscribe(this); + return super.close(); + } } diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart index 2850d7c..371c729 100644 --- a/lib/features/documents/bloc/documents_state.dart +++ b/lib/features/documents/bloc/documents_state.dart @@ -1,16 +1,13 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; -class DocumentsState extends DocumentsPagedState { - final int? selectedSavedViewId; - - @JsonKey(ignore: true) +class DocumentsState extends PagedDocumentsState { + @JsonKey(includeFromJson: true, includeToJson: false) final List selection; const DocumentsState({ this.selection = const [], - this.selectedSavedViewId, super.value = const [], super.filter = const DocumentFilter(), super.hasLoaded = false, @@ -25,7 +22,6 @@ class DocumentsState extends DocumentsPagedState { List>? value, DocumentFilter? filter, List? selection, - int? Function()? selectedSavedViewId, }) { return DocumentsState( hasLoaded: hasLoaded ?? this.hasLoaded, @@ -33,20 +29,13 @@ class DocumentsState extends DocumentsPagedState { value: value ?? this.value, filter: filter ?? this.filter, selection: selection ?? this.selection, - selectedSavedViewId: selectedSavedViewId != null - ? selectedSavedViewId.call() - : this.selectedSavedViewId, ); } @override List get props => [ - hasLoaded, - filter, - value, selection, - isLoading, - selectedSavedViewId, + ...super.props, ]; Map toJson() { @@ -54,7 +43,6 @@ class DocumentsState extends DocumentsPagedState { 'hasLoaded': hasLoaded, 'isLoading': isLoading, 'filter': filter.toJson(), - 'selectedSavedViewId': selectedSavedViewId, 'value': value.map((e) => e.toJson(DocumentModelJsonConverter())).toList(), }; @@ -65,7 +53,6 @@ class DocumentsState extends DocumentsPagedState { return DocumentsState( hasLoaded: json['hasLoaded'], isLoading: json['isLoading'], - selectedSavedViewId: json['selectedSavedViewId'], value: (json['value'] as List) .map((e) => PagedSearchResult.fromJsonT(e, DocumentModelJsonConverter())) diff --git a/lib/features/documents/view/pages/document_edit_page.dart b/lib/features/documents/view/pages/document_edit_page.dart index 9c63df4..b9b8c80 100644 --- a/lib/features/documents/view/pages/document_edit_page.dart +++ b/lib/features/documents/view/pages/document_edit_page.dart @@ -20,7 +20,8 @@ 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.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/constants.dart'; class DocumentEditPage extends StatefulWidget { final FieldSuggestions suggestions; @@ -159,8 +160,7 @@ class _DocumentEditPageState extends State { notAssignedSelectable: false, formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: AddStoragePathPage(initalValue: initialValue), ), textFieldLabel: S.of(context).documentStoragePathPropertyLabel, @@ -181,8 +181,7 @@ class _DocumentEditPageState extends State { notAssignedSelectable: false, formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: AddCorrespondentPage(initialName: initialValue), ), textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, @@ -214,8 +213,7 @@ class _DocumentEditPageState extends State { notAssignedSelectable: false, formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (currentInput) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: AddDocumentTypePage( initialName: currentInput, ), @@ -290,7 +288,7 @@ class _DocumentEditPageState extends State { label: Text(S.of(context).documentCreatedPropertyLabel), ), initialValue: initialCreatedAtDate, - format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format + format: DateFormat.yMMMMd(), initialEntryMode: DatePickerEntryMode.calendar, ), if (_filteredSuggestions.hasSuggestedDates) diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 088a5bf..6074712 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,32 +1,33 @@ +import 'dart:developer'; + import 'package:badges/badges.dart' as b; import 'package:collection/collection.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/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; -import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; +import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; +import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.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/list/adaptive_documents_view.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; -import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart'; -import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart'; +import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; +import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart'; +import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.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/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; class DocumentFilterIntent { final DocumentFilter? filter; @@ -38,6 +39,7 @@ class DocumentFilterIntent { }); } +//TODO: Refactor this class DocumentsPage extends StatefulWidget { const DocumentsPage({Key? key}) : super(key: key); @@ -45,52 +47,38 @@ class DocumentsPage extends StatefulWidget { State createState() => _DocumentsPageState(); } -class _DocumentsPageState extends State { - final ScrollController _scrollController = ScrollController(); - double _offset = 0; - double _last = 0; +class _DocumentsPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; - static const double _savedViewWidgetHeight = 80 + 16; + int _currentTab = 0; @override void initState() { super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: 0, + ); try { context.read().reload(); context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } - _scrollController - ..addListener(_listenForScrollChanges) - ..addListener(_listenForLoadNewData); + _tabController.addListener(_listenForTabChanges); } - void _listenForLoadNewData() { - final currState = context.read().state; - if (_scrollController.offset >= - _scrollController.position.maxScrollExtent * 0.75 && - !currState.isLoading && - !currState.isLastPageLoaded) { - _loadNewPage(); - } - } - - void _listenForScrollChanges() { - final current = _scrollController.offset; - _offset += _last - current; - - if (_offset <= -_savedViewWidgetHeight) _offset = -_savedViewWidgetHeight; - if (_offset >= 0) _offset = 0; - _last = current; - if (_offset <= 0 && _offset >= -_savedViewWidgetHeight) { - setState(() {}); - } + void _listenForTabChanges() { + setState(() { + _currentTab = _tabController.index; + }); } @override void dispose() { - _scrollController.dispose(); + _tabController.dispose(); super.dispose(); } @@ -127,77 +115,8 @@ class _DocumentsPageState extends State { } }, builder: (context, connectivityState) { - const linearProgressIndicatorHeight = 4.0; return Scaffold( - drawer: BlocProvider.value( - value: context.read(), - child: AppDrawer( - afterInboxClosed: () => context.read().reload(), - ), - ), - appBar: PreferredSize( - preferredSize: const Size.fromHeight( - kToolbarHeight + linearProgressIndicatorHeight, - ), - child: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return AppBar( - title: Text( - "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})", - ), - actions: [ - const SortDocumentsButton(), - BlocBuilder( - builder: (context, settingsState) => IconButton( - icon: Icon( - settingsState.preferredViewType == ViewType.grid - ? Icons.list - : Icons.grid_view_rounded, - ), - onPressed: () { - // Reset saved view widget position as scroll offset will be reset anyway. - setState(() { - _offset = 0; - _last = 0; - }); - final cubit = - context.read(); - cubit.setViewType( - cubit.state.preferredViewType.toggle()); - }, - ), - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight( - linearProgressIndicatorHeight), - child: state.isLoading && state.hasLoaded - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), - ), - ); - } else { - return AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => - context.read().resetSelection(), - ), - title: Text( - '${state.selection.length} ${S.of(context).documentsSelectedText}'), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _onDelete(context, state), - ), - ], - ); - } - }, - ), - ), + drawer: const AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { final appliedFiltersCount = state.filter.appliedFiltersCount; @@ -212,10 +131,15 @@ class _DocumentsPageState extends State { ), animationType: b.BadgeAnimationType.fade, badgeColor: Colors.red, - child: FloatingActionButton( - child: const Icon(Icons.filter_alt_outlined), - onPressed: _openDocumentFilter, - ), + child: _currentTab == 0 + ? FloatingActionButton( + child: const Icon(Icons.filter_alt_outlined), + onPressed: _openDocumentFilter, + ) + : FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () => _onCreateSavedView(state.filter), + ), ); }, ), @@ -227,35 +151,200 @@ class _DocumentsPageState extends State { } return false; }, - child: RefreshIndicator( - onRefresh: _onRefresh, - notificationPredicate: (_) => connectivityState.isConnected, - child: BlocBuilder( - builder: (context, taskState) { - return Stack( - children: [ - _buildBody(connectivityState), - Positioned( - left: 0, - right: 0, - top: _offset, - child: BlocBuilder( - builder: (context, state) { - return ColoredBox( - color: Theme.of(context).colorScheme.background, - child: SavedViewSelectionWidget( - height: _savedViewWidgetHeight, - currentFilter: state.filter, - enabled: state.selection.isEmpty && - connectivityState.isConnected, - ), - ); - }, + child: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + // This widget takes the overlapping behavior of the SliverAppBar, + // and redirects it to the SliverOverlapInjector below. If it is + // missing, then it is possible for the nested "inner" scroll view + // below to end up under the SliverAppBar even when the inner + // scroll view thinks it has not been scrolled. + // This is not necessary if the "headerSliverBuilder" only builds + // widgets that do not overlap the next sliver. + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: BlocBuilder( + builder: (context, state) { + if (state.selection.isNotEmpty) { + return SliverAppBar( + floating: false, + pinned: true, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context + .read() + .resetSelection(), + ), + title: Text( + "${state.selection.length} ${S.of(context).documentsSelectedText}", + ), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(state), + ), + ], + ); + } + return SearchAppBar( + hintText: S.of(context).documentSearchSearchDocuments, + onOpenSearch: showDocumentSearchPage, + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: S.of(context).documentsPageTitle), + Tab(text: S.of(context).savedViewsLabel), + ], ), + ); + }, + ), + ), + ], + 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: NotificationListener( + onNotification: (notification) { + // Listen for scroll notifications to load new data. + // Scroll controller does not work here due to nestedscrollview limitations. + final currState = context.read().state; + final max = notification.metrics.maxScrollExtent; + if (max == 0 || + _currentTab != 0 || + currState.isLoading || + currState.isLastPageLoaded) { + return true; + } + final offset = notification.metrics.pixels; + if (offset >= max * 0.7) { + context + .read() + .loadMore() + .onError( + (error, stackTrace) => showErrorMessage( + context, + error, + stackTrace, + ), + ); + } + return false; + }, + child: TabBarView( + controller: _tabController, + children: [ + Builder( + builder: (context) { + return RefreshIndicator( + edgeOffset: kToolbarHeight + kTextTabBarHeight, + onRefresh: _onReloadDocuments, + notificationPredicate: (_) => + connectivityState.isConnected, + child: CustomScrollView( + key: const PageStorageKey("documents"), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor( + context), + ), + _buildViewActions(), + BlocBuilder( + // Not required anymore since saved views are now handled separately + // buildWhen: (previous, current) => + // !const ListEquality().equals( + // previous.documents, + // current.documents, + // ) || + // previous.selectedIds != + // current.selectedIds, + builder: (context, state) { + if (state.hasLoaded && + state.documents.isEmpty) { + return SliverToBoxAdapter( + child: DocumentsEmptyState( + state: state, + onReset: () { + context + .read() + .resetFilter(); + }, + ), + ); + } + return BlocBuilder< + ApplicationSettingsCubit, + ApplicationSettingsState>( + builder: (context, settings) { + return SliverAdaptiveDocumentsView( + viewType: + settings.preferredViewType, + onTap: _openDetails, + onSelected: context + .read() + .toggleDocumentSelection, + hasInternetConnection: + connectivityState.isConnected, + onTagSelected: _addTagToFilter, + onCorrespondentSelected: + _addCorrespondentToFilter, + onDocumentTypeSelected: + _addDocumentTypeToFilter, + onStoragePathSelected: + _addStoragePathToFilter, + documents: state.documents, + hasLoaded: state.hasLoaded, + isLabelClickable: true, + isLoading: state.isLoading, + selectedDocumentIds: + state.selectedIds, + ); + }, + ); + }, + ), + ], + ), + ); + }, + ), + Builder( + builder: (context) { + return RefreshIndicator( + edgeOffset: kToolbarHeight + kTextTabBarHeight, + onRefresh: _onReloadSavedViews, + notificationPredicate: (_) => + connectivityState.isConnected, + child: CustomScrollView( + key: const PageStorageKey("savedViews"), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor( + context), + ), + const SavedViewList(), + ], + ), + ); + }, ), ], - ); - }, + ), + ), ), ), ), @@ -265,7 +354,33 @@ class _DocumentsPageState extends State { ); } - void _onDelete(BuildContext context, DocumentsState documentsState) async { + Widget _buildViewActions() { + return SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortDocumentsButton(), + BlocBuilder( + builder: (context, state) { + return IconButton( + icon: Icon( + state.preferredViewType == ViewType.list + ? Icons.grid_view_rounded + : Icons.list, + ), + onPressed: () => + context.read().setViewType( + state.preferredViewType.toggle(), + ), + ); + }, + ) + ], + ).paddedSymmetrically(horizontal: 8, vertical: 4), + ); + } + + void _onDelete(DocumentsState documentsState) async { final shouldDelete = await showDialog( context: context, builder: (context) => @@ -276,7 +391,7 @@ class _DocumentsPageState extends State { try { await context .read() - .bulkRemove(documentsState.selection); + .bulkDelete(documentsState.selection); showSnackBar( context, S.of(context).documentsPageBulkDeleteSuccessfulText, @@ -288,6 +403,25 @@ class _DocumentsPageState extends State { } } + void _onCreateSavedView(DocumentFilter filter) async { + final newView = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LabelsBlocProvider( + child: AddSavedViewPage( + currentFilter: filter, + ), + ), + ), + ); + if (newView != null) { + try { + await context.read().add(newView); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + void _openDocumentFilter() async { final draggableSheetController = DraggableScrollableController(); final filterIntent = await showModalBottomSheet( @@ -323,12 +457,7 @@ class _DocumentsPageState extends State { try { if (filterIntent.shouldReset) { await context.read().resetFilter(); - context.read().unselectView(); } else { - if (filterIntent.filter != - context.read().state.filter) { - context.read().unselectView(); - } await context .read() .updateFilter(filter: filterIntent.filter!); @@ -339,73 +468,12 @@ class _DocumentsPageState extends State { } } - String _formatDocumentCount(int count) { - return count > 99 ? "99+" : count.toString(); - } - - Widget _buildBody(ConnectivityState connectivityState) { - final isConnected = connectivityState == ConnectivityState.connected; - return BlocBuilder( - builder: (context, settings) { - return BlocBuilder( - buildWhen: (previous, current) => - !const ListEquality() - .equals(previous.documents, current.documents) || - previous.selectedIds != current.selectedIds, - builder: (context, state) { - // Some ugly tricks to make it work with bloc, update pageController - - if (state.hasLoaded && state.documents.isEmpty) { - return DocumentsEmptyState( - state: state, - onReset: () { - context.read().resetFilter(); - context.read().unselectView(); - }, - ); - } - - return AdaptiveDocumentsView( - viewType: settings.preferredViewType, - state: state, - scrollController: _scrollController, - onTap: _openDetails, - onSelected: _onSelected, - hasInternetConnection: isConnected, - onTagSelected: _addTagToFilter, - onCorrespondentSelected: _addCorrespondentToFilter, - onDocumentTypeSelected: _addDocumentTypeToFilter, - onStoragePathSelected: _addStoragePathToFilter, - pageLoadingWidget: const NewItemsLoadingWidget(), - beforeItems: const SizedBox(height: _savedViewWidgetHeight), - ); - }, - ); - }, - ); - } - - Future _openDetails(DocumentModel document) async { - final potentiallyUpdatedModel = - await Navigator.of(context).push( - _buildDetailsPageRoute(document), - ); - if (potentiallyUpdatedModel != document) { - context.read().reload(); - } - } - - MaterialPageRoute _buildDetailsPageRoute( - DocumentModel document) { - return MaterialPageRoute( - builder: (_) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - document, - ), - child: const LabelRepositoriesProvider( - child: DocumentDetailsPage(), - ), + void _openDetails(DocumentModel document) { + Navigator.pushNamed( + context, + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: document, ), ); } @@ -491,23 +559,19 @@ class _DocumentsPageState extends State { } } - Future _loadNewPage() async { + Future _onReloadDocuments() async { try { - await context.read().loadMore(); + // We do not await here on purpose so we can show a linear progress indicator below the app bar. + await context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } } - void _onSelected(DocumentModel model) { - context.read().toggleDocumentSelection(model); - } - - Future _onRefresh() async { + Future _onReloadSavedViews() async { try { // We do not await here on purpose so we can show a linear progress indicator below the app bar. - context.read().reload(); - context.read().reload(); + await context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } diff --git a/lib/features/documents/view/widgets/adaptive_documents_view.dart b/lib/features/documents/view/widgets/adaptive_documents_view.dart new file mode 100644 index 0000000..eb997a5 --- /dev/null +++ b/lib/features/documents/view/widgets/adaptive_documents_view.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/document_grid_loading_widget.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; +import 'package:paperless_mobile/features/settings/model/view_type.dart'; + +abstract class AdaptiveDocumentsView extends StatelessWidget { + final List documents; + final bool isLoading; + final bool hasLoaded; + final bool enableHeroAnimation; + final List selectedDocumentIds; + final ViewType viewType; + final void Function(DocumentModel)? onTap; + final void Function(DocumentModel)? onSelected; + final bool hasInternetConnection; + final bool isLabelClickable; + final void Function(int id)? onTagSelected; + final void Function(int? id)? onCorrespondentSelected; + final void Function(int? id)? onDocumentTypeSelected; + final void Function(int? id)? onStoragePathSelected; + + bool get showLoadingPlaceholder => (!hasLoaded && isLoading); + const AdaptiveDocumentsView({ + super.key, + this.selectedDocumentIds = const [], + required this.documents, + this.onTap, + this.onSelected, + this.viewType = ViewType.list, + required this.hasInternetConnection, + required this.isLabelClickable, + this.onTagSelected, + this.onCorrespondentSelected, + this.onDocumentTypeSelected, + this.onStoragePathSelected, + required this.isLoading, + required this.hasLoaded, + this.enableHeroAnimation = true, + }); +} + +class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView { + const SliverAdaptiveDocumentsView({ + super.key, + required super.documents, + required super.hasInternetConnection, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onStoragePathSelected, + super.onSelected, + super.onTagSelected, + super.onTap, + super.selectedDocumentIds, + super.viewType, + super.enableHeroAnimation, + required super.isLoading, + required super.hasLoaded, + }); + + @override + Widget build(BuildContext context) { + switch (viewType) { + case ViewType.grid: + return _buildGridView(); + case ViewType.list: + return _buildListView(); + } + } + + Widget _buildListView() { + if (showLoadingPlaceholder) { + return DocumentsListLoadingWidget.sliver(); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: documents.length, + (context, index) { + final document = documents.elementAt(index); + return LabelRepositoriesProvider( + child: DocumentListItem( + isLabelClickable: isLabelClickable, + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ), + ); + }, + ), + ); + } + + Widget _buildGridView() { + if (showLoadingPlaceholder) { + return DocumentGridLoadingWidget.sliver(); + } + return SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ), + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return DocumentGridItem( + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + isLabelClickable: isLabelClickable, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ); + }, + ); + } +} + +class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView { + final ScrollController? scrollController; + const DefaultAdaptiveDocumentsView({ + super.key, + required super.documents, + required super.hasInternetConnection, + required super.isLabelClickable, + required super.isLoading, + required super.hasLoaded, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onStoragePathSelected, + super.onSelected, + super.onTagSelected, + super.onTap, + this.scrollController, + super.selectedDocumentIds, + super.viewType, + super.enableHeroAnimation = true, + }); + + @override + Widget build(BuildContext context) { + switch (viewType) { + case ViewType.grid: + return _buildGridView(); + case ViewType.list: + return _buildListView(); + } + } + + Widget _buildListView() { + if (showLoadingPlaceholder) { + return DocumentsListLoadingWidget(); + } + + return ListView.builder( + controller: scrollController, + primary: false, + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return LabelRepositoriesProvider( + child: DocumentListItem( + isLabelClickable: isLabelClickable, + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ), + ); + }, + ); + } + + Widget _buildGridView() { + if (showLoadingPlaceholder) { + return DocumentGridLoadingWidget(); + } + return GridView.builder( + controller: scrollController, + primary: false, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ), + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return DocumentGridItem( + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + isLabelClickable: isLabelClickable, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ); + }, + ); + } +} diff --git a/lib/features/documents/view/widgets/document_grid_loading_widget.dart b/lib/features/documents/view/widgets/document_grid_loading_widget.dart new file mode 100644 index 0000000..d18d5d9 --- /dev/null +++ b/lib/features/documents/view/widgets/document_grid_loading_widget.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.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/document_preview.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/placeholder/document_item_placeholder.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/placeholder/text_placeholder.dart'; +import 'package:shimmer/shimmer.dart'; + +class DocumentGridLoadingWidget extends StatelessWidget + with DocumentItemPlaceholder { + final bool _isSliver; + @override + final Random random = Random(1257195195); + DocumentGridLoadingWidget({super.key}) : _isSliver = false; + + DocumentGridLoadingWidget.sliver({super.key}) : _isSliver = true; + + @override + Widget build(BuildContext context) { + const delegate = SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ); + if (_isSliver) { + return SliverGrid.builder( + gridDelegate: delegate, + itemBuilder: (context, index) => _buildPlaceholderGridItem(context), + ); + } + return GridView.builder( + gridDelegate: delegate, + itemBuilder: (context, index) => _buildPlaceholderGridItem(context), + ); + } + + Widget _buildPlaceholderGridItem(BuildContext context) { + final values = nextValues; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + elevation: 1.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShimmerPlaceholder( + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + color: Colors.white, + ), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ShimmerPlaceholder( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextPlaceholder( + length: values.correspondentLength, + fontSize: 16, + ).padded(1), + TextPlaceholder( + length: values.titleLength, + fontSize: 16, + ), + if (values.tagCount > 0) ...[ + const Spacer(), + TagsPlaceholder( + count: values.tagCount, + dense: true, + ), + ], + const Spacer(), + TextPlaceholder( + length: 100, + fontSize: + Theme.of(context).textTheme.bodySmall!.fontSize!, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 050507c..4c3d25b 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -2,17 +2,16 @@ import 'package:flutter/material.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/documents/bloc/documents_cubit.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class DocumentsEmptyState extends StatelessWidget { - final DocumentsState state; - final VoidCallback onReset; + final PagedDocumentsState state; + final VoidCallback? onReset; const DocumentsEmptyState({ Key? key, required this.state, - required this.onReset, + this.onReset, }) : super(key: key); @override @@ -21,7 +20,7 @@ class DocumentsEmptyState extends StatelessWidget { child: EmptyState( title: S.of(context).documentsPageEmptyStateOopsText, subtitle: S.of(context).documentsPageEmptyStateNothingHereText, - bottomChild: state.filter != DocumentFilter.initial + bottomChild: state.filter != DocumentFilter.initial && onReset != null ? TextButton( onPressed: onReset, child: Text( diff --git a/lib/features/documents/view/widgets/documents_list_loading_widget.dart b/lib/features/documents/view/widgets/documents_list_loading_widget.dart new file mode 100644 index 0000000..034c074 --- /dev/null +++ b/lib/features/documents/view/widgets/documents_list_loading_widget.dart @@ -0,0 +1,82 @@ +import 'dart:math'; + +import 'package:flutter/material.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/placeholder/document_item_placeholder.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/placeholder/text_placeholder.dart'; + +class DocumentsListLoadingWidget extends StatelessWidget + with DocumentItemPlaceholder { + final bool _isSliver; + DocumentsListLoadingWidget({super.key}) : _isSliver = false; + + DocumentsListLoadingWidget.sliver({super.key}) : _isSliver = true; + + @override + final Random random = Random(1209571050); + + @override + Widget build(BuildContext context) { + if (_isSliver) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildFakeListItem(context), + ), + ); + } else { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => _buildFakeListItem(context), + ); + } + } + + Widget _buildFakeListItem(BuildContext context) { + const fontSize = 14.0; + final values = nextValues; + return ShimmerPlaceholder( + child: ListTile( + contentPadding: const EdgeInsets.all(8), + dense: true, + isThreeLine: true, + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.white, + height: double.infinity, + width: 35, + ), + ), + title: Row( + children: [ + TextPlaceholder( + length: values.correspondentLength, + fontSize: fontSize, + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextPlaceholder( + length: values.titleLength, + fontSize: fontSize, + ), + if (values.tagCount > 0) + TagsPlaceholder(count: values.tagCount, dense: true), + TextPlaceholder( + length: 100, + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize!, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/grid/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart similarity index 78% rename from lib/features/documents/view/widgets/grid/document_grid_item.dart rename to lib/features/documents/view/widgets/items/document_grid_item.dart index 00a50be..4c494e8 100644 --- a/lib/features/documents/view/widgets/grid/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -1,38 +1,35 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:intl/intl.dart'; -class DocumentGridItem extends StatelessWidget { - final DocumentModel document; - final bool isSelected; - final void Function(DocumentModel) onTap; - final void Function(DocumentModel) onSelected; - final bool isAtLeastOneSelected; - final bool Function(int tagId) isTagSelectedPredicate; - final void Function(int tagId)? onTagSelected; - +class DocumentGridItem extends DocumentItem { const DocumentGridItem({ - Key? key, - required this.document, - required this.onTap, - required this.onSelected, - required this.isSelected, - required this.isAtLeastOneSelected, - required this.isTagSelectedPredicate, - required this.onTagSelected, - }) : super(key: key); + super.key, + required super.document, + required super.isSelected, + required super.isSelectionActive, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onSelected, + super.onStoragePathSelected, + super.onTagSelected, + super.onTap, + required super.enableHeroAnimation, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, - onLongPress: () => onSelected(document), + onLongPress: onSelected != null ? () => onSelected!(document) : null, child: AbsorbPointer( - absorbing: isAtLeastOneSelected, + absorbing: isSelectionActive, child: Padding( padding: const EdgeInsets.all(8.0), child: Card( @@ -48,6 +45,7 @@ class DocumentGridItem extends StatelessWidget { child: DocumentPreview( id: document.id, borderRadius: 12.0, + enableHero: enableHeroAnimation, ), ), Expanded( @@ -94,10 +92,10 @@ class DocumentGridItem extends StatelessWidget { } void _onTap() { - if (isAtLeastOneSelected || isSelected) { - onSelected(document); + if (isSelectionActive || isSelected) { + onSelected?.call(document); } else { - onTap(document); + onTap?.call(document); } } } diff --git a/lib/features/documents/view/widgets/items/document_item.dart b/lib/features/documents/view/widgets/items/document_item.dart new file mode 100644 index 0000000..a19fef3 --- /dev/null +++ b/lib/features/documents/view/widgets/items/document_item.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; + +abstract class DocumentItem extends StatelessWidget { + final DocumentModel document; + final void Function(DocumentModel)? onTap; + final void Function(DocumentModel)? onSelected; + final bool isSelected; + final bool isSelectionActive; + final bool isLabelClickable; + final bool enableHeroAnimation; + + final void Function(int tagId)? onTagSelected; + final void Function(int? correspondentId)? onCorrespondentSelected; + final void Function(int? documentTypeId)? onDocumentTypeSelected; + final void Function(int? id)? onStoragePathSelected; + + const DocumentItem({ + super.key, + required this.document, + this.onTap, + this.onSelected, + required this.isSelected, + required this.isSelectionActive, + required this.isLabelClickable, + this.onTagSelected, + this.onCorrespondentSelected, + this.onDocumentTypeSelected, + this.onStoragePathSelected, + required this.enableHeroAnimation, + }); +} diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart new file mode 100644 index 0000000..6732d38 --- /dev/null +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -0,0 +1,145 @@ +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/features/documents/view/widgets/document_preview.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; +import 'package:paperless_mobile/features/labels/bloc/providers/document_type_bloc_provider.dart'; +import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; +import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; + +class DocumentListItem extends DocumentItem { + static const _a4AspectRatio = 1 / 1.4142; + + const DocumentListItem({ + super.key, + required super.document, + required super.isSelected, + required super.isSelectionActive, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onSelected, + super.onStoragePathSelected, + super.onTagSelected, + super.onTap, + super.enableHeroAnimation = true, + }); + + @override + Widget build(BuildContext context) { + return DocumentTypeBlocProvider( + 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, + correspondentId: document.correspondent, + onSelected: onCorrespondentSelected, + ), + ), + ], + ), + Text( + document.title, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + AbsorbPointer( + absorbing: isSelectionActive, + child: TagsWidget( + isClickable: isLabelClickable, + tagIds: document.tags, + isMultiLine: false, + onTagSelected: (id) => onTagSelected?.call(id), + ), + ) + ], + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: + BlocBuilder, LabelState>( + builder: (context, docTypes) { + return 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: + docTypes.labels[document.documentType]?.name, + ), + ] + : null, + ), + ); + }, + ) + // Row( + // children: [ + // Text( + // DateFormat.yMMMd().format(document.created), + // style: Theme.of(context) + // .textTheme + // .bodySmall + // ?.apply(color: Colors.grey), + // ), + // if (document.documentType != null) ...[ + // Text("\u30FB"), + // DocumentTypeWidget( + // documentTypeId: document.documentType, + // textStyle: Theme.of(context).textTheme.bodySmall?.apply( + // color: Colors.grey, + // overflow: TextOverflow.ellipsis, + // ), + // ), + // ], + // ], + // ), + ), + isThreeLine: document.tags.isNotEmpty, + leading: AspectRatio( + aspectRatio: _a4AspectRatio, + child: GestureDetector( + child: DocumentPreview( + id: document.id, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + enableHero: enableHeroAnimation, + ), + ), + ), + contentPadding: const EdgeInsets.all(8.0), + ), + ); + } + + void _onTap() { + if (isSelectionActive || isSelected) { + onSelected?.call(document); + } else { + onTap?.call(document); + } + } +} diff --git a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart deleted file mode 100644 index f7d3267..0000000 --- a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; -import 'package:paperless_mobile/features/settings/model/view_type.dart'; - -class AdaptiveDocumentsView extends StatelessWidget { - final ViewType viewType; - final Widget beforeItems; - final void Function(DocumentModel) onTap; - final void Function(DocumentModel) onSelected; - final ScrollController scrollController; - final DocumentsState state; - final bool hasInternetConnection; - final bool isLabelClickable; - final void Function(int id)? onTagSelected; - final void Function(int? id)? onCorrespondentSelected; - final void Function(int? id)? onDocumentTypeSelected; - final void Function(int? id)? onStoragePathSelected; - final Widget pageLoadingWidget; - - const AdaptiveDocumentsView({ - super.key, - required this.onTap, - required this.scrollController, - required this.state, - required this.onSelected, - required this.hasInternetConnection, - this.isLabelClickable = true, - this.onTagSelected, - this.onCorrespondentSelected, - this.onDocumentTypeSelected, - this.onStoragePathSelected, - required this.pageLoadingWidget, - required this.beforeItems, - required this.viewType, - }); - - @override - Widget build(BuildContext context) { - return CustomScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: beforeItems), - if (viewType == ViewType.list) _buildListView() else _buildGridView(), - ], - ); - } - - SliverList _buildListView() { - return SliverList( - delegate: SliverChildBuilderDelegate( - childCount: state.documents.length, - (context, index) { - final document = state.documents.elementAt(index); - return LabelRepositoriesProvider( - child: DocumentListItem( - isLabelClickable: isLabelClickable, - document: document, - onTap: onTap, - isSelected: state.selectedIds.contains(document.id), - onSelected: onSelected, - isAtLeastOneSelected: state.selection.isNotEmpty, - isTagSelectedPredicate: (int tagId) { - return state.filter.tags is IdsTagsQuery - ? (state.filter.tags as IdsTagsQuery) - .includedIds - .contains(tagId) - : false; - }, - onTagSelected: onTagSelected, - onCorrespondentSelected: onCorrespondentSelected, - onDocumentTypeSelected: onDocumentTypeSelected, - onStoragePathSelected: onStoragePathSelected, - ), - ); - }, - ), - ); - } - - Widget _buildGridView() { - return SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - childAspectRatio: 1 / 2, - ), - itemCount: state.documents.length, - itemBuilder: (context, index) { - if (state.hasLoaded && - state.isLoading && - index == state.documents.length) { - return Center(child: pageLoadingWidget); - } - final document = state.documents.elementAt(index); - return DocumentGridItem( - document: document, - onTap: onTap, - isSelected: state.selectedIds.contains(document.id), - onSelected: onSelected, - isAtLeastOneSelected: state.selection.isNotEmpty, - isTagSelectedPredicate: (int tagId) { - return state.filter.tags is IdsTagsQuery - ? (state.filter.tags as IdsTagsQuery) - .includedIds - .contains(tagId) - : false; - }, - onTagSelected: onTagSelected, - ); - }, - ); - } -} diff --git a/lib/features/documents/view/widgets/list/document_list_item.dart b/lib/features/documents/view/widgets/list/document_list_item.dart deleted file mode 100644 index 0c0490b..0000000 --- a/lib/features/documents/view/widgets/list/document_list_item.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; -import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; -import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; - -class DocumentListItem extends StatelessWidget { - static const _a4AspectRatio = 1 / 1.4142; - final DocumentModel document; - final void Function(DocumentModel) onTap; - final void Function(DocumentModel)? onSelected; - final bool isSelected; - final bool isAtLeastOneSelected; - final bool isLabelClickable; - final bool Function(int tagId) isTagSelectedPredicate; - - final void Function(int tagId)? onTagSelected; - final void Function(int? correspondentId)? onCorrespondentSelected; - final void Function(int? documentTypeId)? onDocumentTypeSelected; - final void Function(int? id)? onStoragePathSelected; - - const DocumentListItem({ - Key? key, - required this.document, - required this.onTap, - this.onSelected, - required this.isSelected, - required this.isAtLeastOneSelected, - this.isLabelClickable = true, - required this.isTagSelectedPredicate, - this.onTagSelected, - this.onCorrespondentSelected, - this.onDocumentTypeSelected, - this.onStoragePathSelected, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return 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: isAtLeastOneSelected, - child: CorrespondentWidget( - isClickable: isLabelClickable, - correspondentId: document.correspondent, - onSelected: onCorrespondentSelected, - ), - ), - ], - ), - Text( - document.title, - overflow: TextOverflow.ellipsis, - maxLines: document.tags.isEmpty ? 2 : 1, - ), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: AbsorbPointer( - absorbing: isAtLeastOneSelected, - child: TagsWidget( - isClickable: isLabelClickable, - tagIds: document.tags, - isMultiLine: false, - onTagSelected: (id) => onTagSelected?.call(id), - ), - ), - ), - isThreeLine: document.tags.isNotEmpty, - leading: AspectRatio( - aspectRatio: _a4AspectRatio, - child: GestureDetector( - child: DocumentPreview( - id: document.id, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - ), - ), - ), - contentPadding: const EdgeInsets.all(8.0), - ); - } - - void _onTap() { - if (isAtLeastOneSelected || isSelected) { - onSelected?.call(document); - } else { - onTap(document); - } - } -} diff --git a/lib/features/documents/view/widgets/new_items_loading_widget.dart b/lib/features/documents/view/widgets/new_items_loading_widget.dart index dfe4553..042f692 100644 --- a/lib/features/documents/view/widgets/new_items_loading_widget.dart +++ b/lib/features/documents/view/widgets/new_items_loading_widget.dart @@ -1,10 +1,11 @@ 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 const CircularProgressIndicator(); + return Center(child: const CircularProgressIndicator().padded()); } } diff --git a/lib/features/documents/view/widgets/order_by_dropdown.dart b/lib/features/documents/view/widgets/order_by_dropdown.dart deleted file mode 100644 index 2432e6e..0000000 --- a/lib/features/documents/view/widgets/order_by_dropdown.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:paperless_api/paperless_api.dart'; - -class OrderByDropdown extends StatefulWidget { - static const fkOrderBy = "orderBy"; - const OrderByDropdown({super.key}); - - @override - State createState() => _OrderByDropdownState(); -} - -class _OrderByDropdownState extends State { - @override - Widget build(BuildContext context) { - return FormBuilderDropdown( - name: OrderByDropdown.fkOrderBy, - items: const [], - ); - } -} diff --git a/lib/features/documents/view/widgets/placeholder/document_item_placeholder.dart b/lib/features/documents/view/widgets/placeholder/document_item_placeholder.dart new file mode 100644 index 0000000..951e5ff --- /dev/null +++ b/lib/features/documents/view/widgets/placeholder/document_item_placeholder.dart @@ -0,0 +1,30 @@ +import 'dart:math'; + +mixin DocumentItemPlaceholder { + static const _tags = [" ", " ", " "]; + static const _titleLengths = [double.infinity, 150.0, 200.0]; + static const _correspondentLengths = [120.0, 80.0, 40.0]; + + Random get random; + + RandomDocumentItemPlaceholderValues get nextValues { + return RandomDocumentItemPlaceholderValues( + tagCount: random.nextInt(_tags.length + 1), + correspondentLength: _correspondentLengths[ + random.nextInt(_correspondentLengths.length - 1)], + titleLength: _titleLengths[random.nextInt(_titleLengths.length - 1)], + ); + } +} + +class RandomDocumentItemPlaceholderValues { + final int tagCount; + final double correspondentLength; + final double titleLength; + + RandomDocumentItemPlaceholderValues({ + required this.tagCount, + required this.correspondentLength, + required this.titleLength, + }); +} diff --git a/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart b/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart new file mode 100644 index 0000000..757f3ef --- /dev/null +++ b/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class TagsPlaceholder extends StatelessWidget { + static const _lengths = [24, 36, 16, 48]; + final int count; + final bool dense; + const TagsPlaceholder({ + super.key, + required this.count, + required this.dense, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: ListView.separated( + itemCount: count, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => FilterChip( + labelPadding: + dense ? const EdgeInsets.symmetric(horizontal: 2) : null, + padding: dense ? const EdgeInsets.all(4) : null, + visualDensity: const VisualDensity(vertical: -2), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide.none, + onSelected: (_) {}, + selected: false, + label: Text( + List.filled(_lengths[index], " ").join(), + ), + ), + separatorBuilder: (context, _) => const SizedBox(width: 4), + ), + ); + } +} diff --git a/lib/features/documents/view/widgets/placeholder/text_placeholder.dart b/lib/features/documents/view/widgets/placeholder/text_placeholder.dart new file mode 100644 index 0000000..ef02729 --- /dev/null +++ b/lib/features/documents/view/widgets/placeholder/text_placeholder.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; + +class TextPlaceholder extends StatelessWidget { + final double length; + final double fontSize; + + const TextPlaceholder({ + super.key, + required this.length, + required this.fontSize, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.white, + width: length, + height: fontSize, + ); + } +} diff --git a/lib/features/documents/view/widgets/search/document_filter_form.dart b/lib/features/documents/view/widgets/search/document_filter_form.dart new file mode 100644 index 0000000..f0157ac --- /dev/null +++ b/lib/features/documents/view/widgets/search/document_filter_form.dart @@ -0,0 +1,214 @@ +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/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_state.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/generated/l10n.dart'; + +import 'text_query_form_field.dart'; + +class DocumentFilterForm extends StatefulWidget { + static const fkCorrespondent = DocumentModel.correspondentKey; + static const fkDocumentType = DocumentModel.documentTypeKey; + static const fkStoragePath = DocumentModel.storagePathKey; + static const fkQuery = "query"; + static const fkCreatedAt = DocumentModel.createdKey; + static const fkAddedAt = DocumentModel.addedKey; + + static DocumentFilter assembleFilter( + GlobalKey formKey, DocumentFilter initialFilter) { + formKey.currentState?.save(); + final v = formKey.currentState!.value; + return DocumentFilter( + correspondent: + v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ?? + DocumentFilter.initial.correspondent, + documentType: v[DocumentFilterForm.fkDocumentType] as IdQueryParameter? ?? + DocumentFilter.initial.documentType, + storagePath: v[DocumentFilterForm.fkStoragePath] as IdQueryParameter? ?? + DocumentFilter.initial.storagePath, + tags: + v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags, + query: v[DocumentFilterForm.fkQuery] as TextQuery? ?? + 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, + ); + } + + final Widget? header; + final GlobalKey formKey; + final DocumentFilter initialFilter; + final ScrollController? scrollController; + final EdgeInsets padding; + const DocumentFilterForm({ + super.key, + this.header, + required this.formKey, + required this.initialFilter, + this.scrollController, + this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + }); + + @override + State createState() => _DocumentFilterFormState(); +} + +class _DocumentFilterFormState extends State { + late bool _allowOnlyExtendedQuery; + + @override + void initState() { + super.initState(); + _allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery; + } + + @override + Widget build(BuildContext context) { + return FormBuilder( + key: widget.formKey, + child: CustomScrollView( + controller: widget.scrollController, + slivers: [ + if (widget.header != null) widget.header!, + ..._buildFormFieldList(), + SliverToBoxAdapter( + child: SizedBox( + height: 32, + ), + ), + ], + ), + ); + } + + List _buildFormFieldList() { + return [ + _buildQueryFormField(), + Align( + alignment: Alignment.centerLeft, + child: Text( + S.of(context).documentFilterAdvancedLabel, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + FormBuilderExtendedDateRangePicker( + name: DocumentFilterForm.fkCreatedAt, + initialValue: widget.initialFilter.created, + labelText: S.of(context).documentCreatedPropertyLabel, + onChanged: (_) { + _checkQueryConstraints(); + }, + ), + FormBuilderExtendedDateRangePicker( + name: DocumentFilterForm.fkAddedAt, + initialValue: widget.initialFilter.added, + labelText: S.of(context).documentAddedPropertyLabel, + onChanged: (_) { + _checkQueryConstraints(); + }, + ), + _buildCorrespondentFormField(), + _buildDocumentTypeFormField(), + _buildStoragePathFormField(), + _buildTagsFormField(), + ] + .map((w) => SliverPadding( + padding: widget.padding, + sliver: SliverToBoxAdapter(child: w), + )) + .toList(); + } + + void _checkQueryConstraints() { + final filter = + DocumentFilterForm.assembleFilter(widget.formKey, widget.initialFilter); + if (filter.forceExtendedQuery) { + setState(() => _allowOnlyExtendedQuery = true); + final queryField = + widget.formKey.currentState?.fields[DocumentFilterForm.fkQuery]; + queryField?.didChange( + (queryField.value as TextQuery?) + ?.copyWith(queryType: QueryType.extended), + ); + } else { + setState(() => _allowOnlyExtendedQuery = false); + } + } + + Widget _buildDocumentTypeFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkDocumentType, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, + initialValue: widget.initialFilter.documentType, + prefixIcon: const Icon(Icons.description_outlined), + ); + }, + ); + } + + Widget _buildCorrespondentFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkCorrespondent, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, + initialValue: widget.initialFilter.correspondent, + prefixIcon: const Icon(Icons.person_outline), + ); + }, + ); + } + + Widget _buildStoragePathFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkStoragePath, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentStoragePathPropertyLabel, + initialValue: widget.initialFilter.storagePath, + prefixIcon: const Icon(Icons.folder_outlined), + ); + }, + ); + } + + Widget _buildQueryFormField() { + return TextQueryFormField( + name: DocumentFilterForm.fkQuery, + onlyExtendedQueryAllowed: _allowOnlyExtendedQuery, + initialValue: widget.initialFilter.query, + ); + } + + BlocBuilder, LabelState> _buildTagsFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return TagFormField( + name: DocumentModel.tagsKey, + initialValue: widget.initialFilter.tags, + allowCreation: false, + selectableOptions: state.labels, + ); + }, + ); + } +} 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 028b65b..8cc5e5f 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/text_query_form_field.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; @@ -32,22 +33,14 @@ class DocumentFilterPanel extends StatefulWidget { } class _DocumentFilterPanelState extends State { - static const fkCorrespondent = DocumentModel.correspondentKey; - static const fkDocumentType = DocumentModel.documentTypeKey; - static const fkStoragePath = DocumentModel.storagePathKey; - static const fkQuery = "query"; - static const fkCreatedAt = DocumentModel.createdKey; - static const fkAddedAt = DocumentModel.addedKey; - final _formKey = GlobalKey(); - late bool _allowOnlyExtendedQuery; double _heightAnimationValue = 0; @override void initState() { super.initState(); - _allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery; + widget.draggableSheetController.addListener(animateTitleByDrag); } @@ -106,100 +99,59 @@ class _DocumentFilterPanelState extends State { ), ), resizeToAvoidBottomInset: true, - body: FormBuilder( - key: _formKey, - child: _buildFormList(context), + body: DocumentFilterForm( + formKey: _formKey, + scrollController: widget.scrollController, + initialFilter: widget.initialFilter, + header: _buildPanelHeader(), ), ), ); } - Widget _buildFormList(BuildContext context) { - return CustomScrollView( - controller: widget.scrollController, - slivers: [ - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - toolbarHeight: kToolbarHeight + 22, - title: SizedBox( - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Opacity( - opacity: 1 - _heightAnimationValue, - child: Padding( - padding: EdgeInsets.only(bottom: 11), - child: _buildDragHandle(), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Opacity( - opacity: max(0, (_heightAnimationValue - 0.5) * 2), - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: const Icon(Icons.expand_more_rounded), - ), - ), - Padding( - padding: - EdgeInsets.only(left: _heightAnimationValue * 48), - child: Text(S.of(context).documentFilterTitle), - ), - ], - ), - ), - ], + Widget _buildPanelHeader() { + return SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + toolbarHeight: kToolbarHeight + 22, + title: SizedBox( + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Opacity( + opacity: 1 - _heightAnimationValue, + child: Padding( + padding: const EdgeInsets.only(bottom: 11), + child: _buildDragHandle(), + ), ), - ), + Align( + alignment: Alignment.centerLeft, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity( + opacity: max(0, (_heightAnimationValue - 0.5) * 2), + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon(Icons.expand_more_rounded), + ), + ), + Padding( + padding: EdgeInsets.only(left: _heightAnimationValue * 48), + child: Text(S.of(context).documentFilterTitle), + ), + ], + ), + ), + ], ), - ..._buildFormFieldList(), - ], + ), ); } - List _buildFormFieldList() { - return [ - _buildQueryFormField().paddedSymmetrically(vertical: 8, horizontal: 16), - Align( - alignment: Alignment.centerLeft, - child: Text( - S.of(context).documentFilterAdvancedLabel, - style: Theme.of(context).textTheme.bodySmall, - ), - ).paddedSymmetrically(vertical: 8, horizontal: 16), - FormBuilderExtendedDateRangePicker( - name: fkCreatedAt, - initialValue: widget.initialFilter.created, - labelText: S.of(context).documentCreatedPropertyLabel, - onChanged: (_) { - _checkQueryConstraints(); - }, - ).paddedSymmetrically(vertical: 8, horizontal: 16), - FormBuilderExtendedDateRangePicker( - name: fkAddedAt, - initialValue: widget.initialFilter.added, - labelText: S.of(context).documentAddedPropertyLabel, - onChanged: (_) { - _checkQueryConstraints(); - }, - ).paddedSymmetrically(vertical: 8, horizontal: 16), - _buildCorrespondentFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildDocumentTypeFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildStoragePathFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildTagsFormField().padded(16), - ].map((w) => SliverToBoxAdapter(child: w)).toList(); - } - Container _buildDragHandle() { return Container( // According to m3 spec https://m3.material.io/components/bottom-sheets/specs @@ -212,19 +164,6 @@ class _DocumentFilterPanelState extends State { ); } - BlocBuilder, LabelState> _buildTagsFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return TagFormField( - name: DocumentModel.tagsKey, - initialValue: widget.initialFilter.tags, - allowCreation: false, - selectableOptions: state.labels, - ); - }, - ); - } - void _resetFilter() async { FocusScope.of(context).unfocus(); Navigator.pop( @@ -233,102 +172,13 @@ class _DocumentFilterPanelState extends State { ); } - Widget _buildDocumentTypeFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkDocumentType, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, - initialValue: widget.initialFilter.documentType, - prefixIcon: const Icon(Icons.description_outlined), - ); - }, - ); - } - - Widget _buildCorrespondentFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkCorrespondent, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, - initialValue: widget.initialFilter.correspondent, - prefixIcon: const Icon(Icons.person_outline), - ); - }, - ); - } - - Widget _buildStoragePathFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkStoragePath, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentStoragePathPropertyLabel, - initialValue: widget.initialFilter.storagePath, - prefixIcon: const Icon(Icons.folder_outlined), - ); - }, - ); - } - - Widget _buildQueryFormField() { - return TextQueryFormField( - name: fkQuery, - onlyExtendedQueryAllowed: _allowOnlyExtendedQuery, - initialValue: widget.initialFilter.query, - ); - } - void _onApplyFilter() async { _formKey.currentState?.save(); if (_formKey.currentState?.validate() ?? false) { - DocumentFilter newFilter = _assembleFilter(); + DocumentFilter newFilter = + DocumentFilterForm.assembleFilter(_formKey, widget.initialFilter); FocusScope.of(context).unfocus(); Navigator.pop(context, DocumentFilterIntent(filter: newFilter)); } } - - DocumentFilter _assembleFilter() { - _formKey.currentState?.save(); - final v = _formKey.currentState!.value; - return DocumentFilter( - correspondent: v[fkCorrespondent] as IdQueryParameter? ?? - DocumentFilter.initial.correspondent, - documentType: v[fkDocumentType] as IdQueryParameter? ?? - DocumentFilter.initial.documentType, - storagePath: v[fkStoragePath] as IdQueryParameter? ?? - DocumentFilter.initial.storagePath, - tags: - v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags, - query: v[fkQuery] as TextQuery? ?? DocumentFilter.initial.query, - created: (v[fkCreatedAt] as DateRangeQuery), - added: (v[fkAddedAt] as DateRangeQuery), - asnQuery: widget.initialFilter.asnQuery, - page: 1, - pageSize: widget.initialFilter.pageSize, - sortField: widget.initialFilter.sortField, - sortOrder: widget.initialFilter.sortOrder, - ); - } - - void _checkQueryConstraints() { - final filter = _assembleFilter(); - if (filter.forceExtendedQuery) { - setState(() => _allowOnlyExtendedQuery = true); - final queryField = _formKey.currentState?.fields[fkQuery]; - queryField?.didChange( - (queryField.value as TextQuery?) - ?.copyWith(queryType: QueryType.extended), - ); - } else { - setState(() => _allowOnlyExtendedQuery = false); - } - } } diff --git a/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart b/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart index 73e6dee..a261cfc 100644 --- a/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart +++ b/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; @@ -8,9 +10,9 @@ import 'package:paperless_mobile/generated/l10n.dart'; class SortFieldSelectionBottomSheet extends StatefulWidget { final SortOrder initialSortOrder; - final SortField initialSortField; + final SortField? initialSortField; - final Future Function(SortField field, SortOrder order) onSubmit; + final Future Function(SortField? field, SortOrder order) onSubmit; const SortFieldSelectionBottomSheet({ super.key, @@ -26,7 +28,7 @@ class SortFieldSelectionBottomSheet extends StatefulWidget { class _SortFieldSelectionBottomSheetState extends State { - late SortField _currentSortField; + late SortField? _currentSortField; late SortOrder _currentSortOrder; @override @@ -39,61 +41,90 @@ class _SortFieldSelectionBottomSheetState @override Widget build(BuildContext context) { return ClipRRect( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - S.of(context).documentsPageOrderByLabel, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.start, - ), - TextButton( - child: Text(S.of(context).documentFilterApplyFilterLabel), - onPressed: () { - widget.onSubmit( - _currentSortField, - _currentSortOrder, - ); - Navigator.pop(context); - }, - ), - ], - ).paddedSymmetrically(horizontal: 16, vertical: 8.0), - Column( - children: [ - _buildSortOption(SortField.archiveSerialNumber), - BlocBuilder, LabelState>( - builder: (context, state) { - return _buildSortOption( - SortField.correspondentName, - enabled: state.labels.values.fold( - false, - (previousValue, element) => - previousValue || (element.documentCount ?? 0) > 0), - ); - }, - ), - _buildSortOption(SortField.title), - BlocBuilder, LabelState>( - builder: (context, state) { - return _buildSortOption( - SortField.documentType, - enabled: state.labels.values.fold( - false, - (previousValue, element) => - previousValue || (element.documentCount ?? 0) > 0), - ); - }, - ), - _buildSortOption(SortField.created), - _buildSortOption(SortField.added), - _buildSortOption(SortField.modified), - ], - ), - ], + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).documentsPageOrderByLabel, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.start, + ), + TextButton( + child: Text(S.of(context).documentFilterApplyFilterLabel), + onPressed: () { + widget.onSubmit( + _currentSortField, + _currentSortOrder, + ); + Navigator.pop(context); + }, + ), + ], + ).paddedOnly(left: 16, right: 16, top: 8), + Column( + children: [ + _buildSortOption(SortField.archiveSerialNumber), + BlocBuilder, + LabelState>( + builder: (context, state) { + return _buildSortOption( + SortField.correspondentName, + enabled: state.labels.values.fold( + false, + (previousValue, element) => + previousValue || + (element.documentCount ?? 0) > 0), + ); + }, + ), + _buildSortOption(SortField.title), + BlocBuilder, LabelState>( + builder: (context, state) { + return _buildSortOption( + SortField.documentType, + enabled: state.labels.values.fold( + false, + (previousValue, element) => + previousValue || + (element.documentCount ?? 0) > 0), + ); + }, + ), + _buildSortOption(SortField.created), + _buildSortOption(SortField.added), + _buildSortOption(SortField.modified), + const SizedBox(height: 16), + Center( + child: SegmentedButton( + multiSelectionEnabled: false, + showSelectedIcon: false, + segments: [ + ButtonSegment( + icon: const FaIcon(FontAwesomeIcons.arrowDownAZ), + value: SortOrder.descending, + label: Text(S.of(context).sortDocumentDescending), + ), + ButtonSegment( + icon: const FaIcon(FontAwesomeIcons.arrowUpZA), + value: SortOrder.ascending, + label: Text(S.of(context).sortDocumentAscending), + ), + ], + emptySelectionAllowed: false, + selected: {_currentSortOrder}, + onSelectionChanged: (selection) { + setState(() => _currentSortOrder = selection.first); + }, + ), + ).paddedOnly(bottom: 16), + ], + ), + ], + ), ), ); } @@ -101,47 +132,10 @@ class _SortFieldSelectionBottomSheetState Widget _buildSortOption(SortField field, {bool enabled = true}) { return ListTile( enabled: enabled, - contentPadding: const EdgeInsets.symmetric(horizontal: 32), - title: Text( - _localizedSortField(field), - ), - trailing: _currentSortField == field - ? _buildOrderIcon(_currentSortOrder) - : null, - onTap: () { - setState(() { - _currentSortOrder = (_currentSortField == field - ? _currentSortOrder.toggle() - : SortOrder.descending); - _currentSortField = field; - }); - }, + contentPadding: const EdgeInsets.only(left: 32, right: 16), + title: Text(translateSortField(context, field)), + trailing: _currentSortField == field ? const Icon(Icons.done) : null, + onTap: () => setState(() => _currentSortField = field), ); } - - Widget _buildOrderIcon(SortOrder order) { - if (order == SortOrder.ascending) { - return const Icon(Icons.arrow_upward); - } - return const Icon(Icons.arrow_downward); - } - - String _localizedSortField(SortField sortField) { - switch (sortField) { - case SortField.archiveSerialNumber: - return S.of(context).documentArchiveSerialNumberPropertyShortLabel; - case SortField.correspondentName: - return S.of(context).documentCorrespondentPropertyLabel; - case SortField.title: - return S.of(context).documentTitlePropertyLabel; - case SortField.documentType: - return S.of(context).documentDocumentTypePropertyLabel; - case SortField.created: - return S.of(context).documentCreatedPropertyLabel; - case SortField.added: - return S.of(context).documentAddedPropertyLabel; - case SortField.modified: - return S.of(context).documentModifiedPropertyLabel; - } - } } diff --git a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart deleted file mode 100644 index 4303a73..0000000 --- a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:developer'; - -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/offline_banner.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; - -class DocumentsPageAppBar extends StatefulWidget with PreferredSizeWidget { - final List actions; - final bool isOffline; - - const DocumentsPageAppBar({ - super.key, - required this.isOffline, - this.actions = const [], - }); - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - @override - State createState() => _DocumentsPageAppBarState(); -} - -class _DocumentsPageAppBarState extends State { - @override - Widget build(BuildContext context) { - const savedViewWidgetHeight = 48.0; - final flexibleAreaHeight = kToolbarHeight - - 16 + - savedViewWidgetHeight + - (widget.isOffline ? 24 : 0); - return BlocBuilder( - builder: (context, documentsState) { - final hasSelection = documentsState.selection.isNotEmpty; - // final PreferredSize? loadingWidget = documentsState.isLoading - // ? const PreferredSize( - // child: LinearProgressIndicator(), - // preferredSize: Size.fromHeight(4.0), - // ) - // : null; - if (hasSelection) { - return SliverAppBar( - // bottom: loadingWidget, - expandedHeight: kToolbarHeight + flexibleAreaHeight, - snap: true, - floating: true, - pinned: true, - flexibleSpace: _buildFlexibleArea( - false, - documentsState.filter, - savedViewWidgetHeight, - ), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => context.read().resetSelection(), - ), - title: Text( - '${documentsState.selection.length} ${S.of(context).documentsSelectedText}'), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _onDelete(context, documentsState), - ), - ], - ); - } else { - return SliverAppBar( - // bottom: loadingWidget, - expandedHeight: kToolbarHeight + flexibleAreaHeight, - snap: true, - floating: true, - pinned: true, - flexibleSpace: _buildFlexibleArea( - true, - documentsState.filter, - savedViewWidgetHeight, - ), - title: Text( - '${S.of(context).documentsPageTitle} (${_formatDocumentCount(documentsState.count)})', - ), - actions: [ - ...widget.actions, - ], - ); - } - }, - ); - } - - Widget _buildFlexibleArea( - bool enabled, - DocumentFilter filter, - double savedViewHeight, - ) { - return FlexibleSpaceBar( - background: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (widget.isOffline) const OfflineBanner(), - SavedViewSelectionWidget( - height: savedViewHeight, - enabled: enabled, - currentFilter: filter, - ).paddedSymmetrically(horizontal: 8.0), - ], - ), - ); - } - - void _onDelete(BuildContext context, DocumentsState documentsState) async { - final shouldDelete = await showDialog( - context: context, - builder: (context) => - BulkDeleteConfirmationDialog(state: documentsState)) ?? - false; - if (shouldDelete) { - try { - await context - .read() - .bulkRemove(documentsState.selection); - showSnackBar( - context, - S.of(context).documentsPageBulkDeleteSuccessfulText, - ); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } - - String _formatDocumentCount(int count) { - return count > 99 ? "99+" : count.toString(); - } -} - -class ScrollListener extends ChangeNotifier { - double top = 0; - double _last = 0; - - ScrollListener.initialise(ScrollController controller, [double height = 56]) { - controller.addListener(() { - final current = controller.offset; - top += _last - current; - if (top <= -height) top = -height; - if (top >= 0) top = 0; - _last = current; - if (top <= 0 && top >= -height) notifyListeners(); - }); - } -} diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index e935e47..d87466b 100644 --- a/lib/features/documents/view/widgets/sort_documents_button.dart +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -4,72 +4,72 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; +import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; class SortDocumentsButton extends StatelessWidget { - const SortDocumentsButton({super.key}); + const SortDocumentsButton({ + super.key, + }); @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.sort), - onPressed: () => _onOpenSortBottomSheet(context), - ); - } - - void _onOpenSortBottomSheet(BuildContext context) { - 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: FractionallySizedBox( - heightFactor: .6, - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => LabelCubit( - context.read< - LabelRepository>(), + return BlocBuilder( + builder: (context, state) { + if (state.filter.sortField == null) { + return const SizedBox.shrink(); + } + return TextButton.icon( + icon: Icon(state.filter.sortOrder == SortOrder.ascending + ? Icons.arrow_upward + : Icons.arrow_downward), + label: Text(translateSortField(context, state.filter.sortField)), + onPressed: () { + showModalBottomSheet( + elevation: 2, + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), ), - BlocProvider( - create: (context) => LabelCubit( - context.read< - LabelRepository>(), - ), - ), - ], - child: BlocBuilder( - builder: (context, state) { - return SortFieldSelectionBottomSheet( - initialSortField: state.filter.sortField, - initialSortOrder: state.filter.sortOrder, - onSubmit: (field, order) => - context.read().updateCurrentFilter( - (filter) => filter.copyWith( - sortField: field, - sortOrder: order, + builder: (_) => BlocProvider.value( + value: context.read(), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => LabelCubit( + context.read>(), + ), + ), + BlocProvider( + create: (context) => LabelCubit( + context.read>(), + ), + ), + ], + child: SortFieldSelectionBottomSheet( + initialSortField: state.filter.sortField, + initialSortOrder: state.filter.sortOrder, + onSubmit: (field, order) => + context.read().updateCurrentFilter( + (filter) => filter.copyWith( + sortField: field, + sortOrder: order, + ), ), - ), - ); - }, - ), - ), - ), - ), + ), + ), + ), + ); + }, + ); + }, ); } } diff --git a/lib/features/documents/view/widgets/view_actions.dart b/lib/features/documents/view/widgets/view_actions.dart new file mode 100644 index 0000000..a69feb0 --- /dev/null +++ b/lib/features/documents/view/widgets/view_actions.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.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'; + +class ViewActions extends StatelessWidget { + const ViewActions({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortDocumentsButton(), + BlocBuilder( + builder: (context, settings) { + final cubit = context.read(); + switch (settings.preferredViewType) { + case ViewType.grid: + return IconButton( + icon: const Icon(Icons.list), + onPressed: () => + cubit.setViewType(settings.preferredViewType.toggle()), + ); + case ViewType.list: + return IconButton( + icon: const Icon(Icons.grid_view_rounded), + onPressed: () => + cubit.setViewType(settings.preferredViewType.toggle()), + ); + } + }, + ) + ], + ); + } +} diff --git a/lib/features/edit_document/cubit/edit_document_cubit.dart b/lib/features/edit_document/cubit/edit_document_cubit.dart index 85be2d1..6942d0b 100644 --- a/lib/features/edit_document/cubit/edit_document_cubit.dart +++ b/lib/features/edit_document/cubit/edit_document_cubit.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.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:collection/collection.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; @@ -16,31 +17,28 @@ class EditDocumentCubit extends Cubit { final DocumentModel _initialDocument; final PaperlessDocumentsApi _docsApi; - final LabelRepository - _correspondentRepository; - final LabelRepository - _documentTypeRepository; - final LabelRepository - _storagePathRepository; - final LabelRepository _tagRepository; - + final DocumentChangedNotifier _notifier; + final LabelRepository _correspondentRepository; + final LabelRepository _documentTypeRepository; + final LabelRepository _storagePathRepository; + final LabelRepository _tagRepository; final List _subscriptions = []; + EditDocumentCubit( DocumentModel document, { required PaperlessDocumentsApi documentsApi, - required LabelRepository - correspondentRepository, - required LabelRepository - documentTypeRepository, - required LabelRepository - storagePathRepository, - required LabelRepository tagRepository, + required LabelRepository correspondentRepository, + required LabelRepository documentTypeRepository, + required LabelRepository storagePathRepository, + required LabelRepository tagRepository, + required DocumentChangedNotifier notifier, }) : _initialDocument = document, _docsApi = documentsApi, _correspondentRepository = correspondentRepository, _documentTypeRepository = documentTypeRepository, _storagePathRepository = storagePathRepository, _tagRepository = tagRepository, + _notifier = notifier, super( EditDocumentState( document: document, @@ -50,6 +48,7 @@ class EditDocumentCubit extends Cubit { tags: tagRepository.current?.values ?? {}, ), ) { + _notifier.subscribe(this, onUpdated: replace); _subscriptions.add( _correspondentRepository.values .listen((v) => emit(state.copyWith(correspondents: v?.values))), @@ -71,6 +70,8 @@ class EditDocumentCubit extends Cubit { Future updateDocument(DocumentModel document) async { final updated = await _docsApi.update(document); + _notifier.notifyUpdated(updated); + // Reload changed labels (documentCount property changes with removal/add) if (document.documentType != _initialDocument.documentType) { _documentTypeRepository @@ -88,7 +89,10 @@ class EditDocumentCubit extends Cubit { .equals(document.tags, _initialDocument.tags)) { _tagRepository.findAll(document.tags); } - emit(state.copyWith(document: updated)); + } + + void replace(DocumentModel document) { + emit(state.copyWith(document: document)); } @override @@ -96,6 +100,7 @@ class EditDocumentCubit extends Cubit { for (final sub in _subscriptions) { sub.cancel(); } + _notifier.unsubscribe(this); return super.close(); } } diff --git a/lib/features/edit_label/cubit/edit_label_cubit.dart b/lib/features/edit_label/cubit/edit_label_cubit.dart index 248ca9d..9ec07e8 100644 --- a/lib/features/edit_label/cubit/edit_label_cubit.dart +++ b/lib/features/edit_label/cubit/edit_label_cubit.dart @@ -3,15 +3,15 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart'; import 'package:paperless_mobile/features/edit_label/cubit/edit_label_state.dart'; class EditLabelCubit extends Cubit> { - final LabelRepository>> _repository; + final LabelRepository _repository; StreamSubscription? _subscription; - EditLabelCubit(LabelRepository>> repository) + EditLabelCubit(LabelRepository repository) : _repository = repository, super(const EditLabelInitial()) { _subscription = repository.values.listen( diff --git a/lib/features/edit_label/view/add_label_page.dart b/lib/features/edit_label/view/add_label_page.dart index 227e032..5b0e593 100644 --- a/lib/features/edit_label/view/add_label_page.dart +++ b/lib/features/edit_label/view/add_label_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.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.dart'; @@ -25,8 +25,7 @@ class AddLabelPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>>>(), + context.read>(), ), child: AddLabelFormWidget( pageTitle: pageTitle, diff --git a/lib/features/edit_label/view/edit_label_page.dart b/lib/features/edit_label/view/edit_label_page.dart index d5b27d7..8fe7d27 100644 --- a/lib/features/edit_label/view/edit_label_page.dart +++ b/lib/features/edit_label/view/edit_label_page.dart @@ -5,11 +5,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/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/indexed_repository_state.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.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/constants.dart'; class EditLabelPage extends StatelessWidget { final T label; @@ -27,8 +28,7 @@ class EditLabelPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>>>(), + context.read>(), ), child: EditLabelForm( label: label, diff --git a/lib/features/edit_label/view/impl/add_correspondent_page.dart b/lib/features/edit_label/view/impl/add_correspondent_page.dart index 9df0cd4..08d4c77 100644 --- a/lib/features/edit_label/view/impl/add_correspondent_page.dart +++ b/lib/features/edit_label/view/impl/add_correspondent_page.dart @@ -15,8 +15,7 @@ class AddCorrespondentPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), child: AddLabelPage( pageTitle: Text(S.of(context).addCorrespondentPageTitle), diff --git a/lib/features/edit_label/view/impl/add_document_type_page.dart b/lib/features/edit_label/view/impl/add_document_type_page.dart index 1fc30ca..e3c19e9 100644 --- a/lib/features/edit_label/view/impl/add_document_type_page.dart +++ b/lib/features/edit_label/view/impl/add_document_type_page.dart @@ -18,8 +18,7 @@ class AddDocumentTypePage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>(), + context.read>(), ), child: AddLabelPage( pageTitle: Text(S.of(context).addDocumentTypePageTitle), 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 c5926e4..3ab343c 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 @@ -16,8 +16,7 @@ class AddStoragePathPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>(), + context.read>(), ), child: AddLabelPage( pageTitle: Text(S.of(context).addStoragePathPageTitle), 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 157db6a..76257eb 100644 --- a/lib/features/edit_label/view/impl/add_tag_page.dart +++ b/lib/features/edit_label/view/impl/add_tag_page.dart @@ -19,7 +19,7 @@ class AddTagPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context.read>(), + context.read>(), ), child: AddLabelPage( pageTitle: Text(S.of(context).addTagPageTitle), 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 e620db9..5c01408 100644 --- a/lib/features/edit_label/view/impl/edit_correspondent_page.dart +++ b/lib/features/edit_label/view/impl/edit_correspondent_page.dart @@ -14,8 +14,7 @@ class EditCorrespondentPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), child: EditLabelPage( label: correspondent, 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 a3a7a9b..d698aec 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 @@ -14,8 +14,7 @@ class EditDocumentTypePage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>(), + context.read>(), ), child: EditLabelPage( label: documentType, 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 2994796..73a66e0 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 @@ -15,8 +15,7 @@ class EditStoragePathPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context - .read>(), + context.read>(), ), child: EditLabelPage( label: storagePath, 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 686873d..678b944 100644 --- a/lib/features/edit_label/view/impl/edit_tag_page.dart +++ b/lib/features/edit_label/view/impl/edit_tag_page.dart @@ -18,7 +18,7 @@ class EditTagPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => EditLabelCubit( - context.read>(), + context.read>(), ), child: EditLabelPage( label: tag, diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index b284b51..1c0d1ea 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -6,7 +6,8 @@ import 'package:paperless_mobile/core/translation/matching_algorithm_localizatio import 'package:paperless_mobile/core/type/types.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/constants.dart'; class SubmitButtonConfig { final Widget icon; @@ -53,8 +54,9 @@ class _LabelFormState extends State> { @override void initState() { super.initState(); - _enableMatchFormField = - widget.initialValue?.matchingAlgorithm != MatchingAlgorithm.auto; + _enableMatchFormField = (widget.initialValue?.matchingAlgorithm ?? + MatchingAlgorithm.defaultValue) != + MatchingAlgorithm.auto; } @override @@ -82,8 +84,9 @@ class _LabelFormState extends State> { ), FormBuilderDropdown( name: Label.matchingAlgorithmKey, - initialValue: widget.initialValue?.matchingAlgorithm.value ?? - MatchingAlgorithm.auto.value, + initialValue: (widget.initialValue?.matchingAlgorithm ?? + MatchingAlgorithm.defaultValue) + .value, decoration: InputDecoration( labelText: S.of(context).labelMatchingAlgorithmPropertyLabel, errorText: _errors[Label.matchingAlgorithmKey], @@ -98,7 +101,8 @@ class _LabelFormState extends State> { .map( (algo) => DropdownMenuItem( child: Text( - translateMatchingAlgorithmDescription(context, algo)), + translateMatchingAlgorithmDescription(context, algo), + ), value: algo.value, ), ) @@ -138,8 +142,8 @@ class _LabelFormState extends State> { // If auto is selected, the match will be removed. mergedJson[Label.matchKey] = ''; } - final createdLabel = await widget.submitButtonConfig - .onSubmit(widget.fromJsonT(mergedJson)); + final parsed = widget.fromJsonT(mergedJson); + final createdLabel = await widget.submitButtonConfig.onSubmit(parsed); Navigator.pop(context, createdLabel); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index 4ec53af..c6e9f9f 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -8,20 +8,22 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/home/view/route_description.dart'; -import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart'; -import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart'; +import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; +import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; +import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_cubit.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/saved_view/cubit/saved_view_cubit.dart'; @@ -30,9 +32,10 @@ import 'package:paperless_mobile/features/scan/view/scanner_page.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.dart'; -import 'package:paperless_mobile/util.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:paperless_mobile/helpers/file_helpers.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:path/path.dart' as p; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:responsive_builder/responsive_builder.dart'; class HomePage extends StatefulWidget { @@ -45,11 +48,20 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { int _currentIndex = 0; final DocumentScannerCubit _scannerCubit = DocumentScannerCubit(); + late final InboxCubit _inboxCubit; @override void initState() { super.initState(); _initializeData(context); + _inboxCubit = InboxCubit( + context.read(), + context.read(), + context.read(), + context.read(), + context.read(), + context.read(), + ); context.read().reload(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _listenForReceivedFiles(); @@ -109,7 +121,6 @@ class _HomePageState extends State { MaterialPageRoute( builder: (context) => BlocProvider.value( value: DocumentUploadCubit( - localVault: context.read(), documentApi: context.read(), tagRepository: context.read(), correspondentRepository: context.read(), @@ -137,7 +148,7 @@ class _HomePageState extends State { toastLength: Toast.LENGTH_LONG, ); } - } catch (e, stackTrace) { + } catch (e) { Fluttertoast.showToast( msg: S.of(context).receiveSharedFilePermissionDeniedMessage, toastLength: Toast.LENGTH_LONG, @@ -145,6 +156,12 @@ class _HomePageState extends State { } } + @override + void dispose() { + _inboxCubit.close(); + super.dispose(); + } + @override Widget build(BuildContext context) { final destinations = [ @@ -172,35 +189,38 @@ class _HomePageState extends State { ), label: S.of(context).bottomNavLabelsPageLabel, ), - // RouteDescription( - // icon: const Icon(Icons.inbox_outlined), - // selectedIcon: Icon( - // Icons.inbox, - // color: Theme.of(context).colorScheme.primary, - // ), - // label: S.of(context).bottomNavInboxPageLabel, - // ), - // RouteDescription( - // icon: const Icon(Icons.settings_outlined), - // selectedIcon: Icon( - // Icons.settings, - // color: Theme.of(context).colorScheme.primary, - // ), - // label: S.of(context).appDrawerSettingsLabel, - // ), + RouteDescription( + icon: const Icon(Icons.inbox_outlined), + selectedIcon: Icon( + Icons.inbox, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavInboxPageLabel, + badgeBuilder: (icon) => BlocBuilder( + bloc: _inboxCubit, + builder: (context, state) { + if (state.itemsInInboxCount > 0) { + return Badge.count( + count: state.itemsInInboxCount, + child: icon, + ); + } + return icon; + }, + )), ]; final routes = [ MultiBlocProvider( providers: [ BlocProvider( create: (context) => DocumentsCubit( - context.read(), - context.read(), + context.read(), + context.read(), ), ), BlocProvider( create: (context) => SavedViewCubit( - context.read(), + context.read(), ), ), ], @@ -210,7 +230,28 @@ class _HomePageState extends State { value: _scannerCubit, child: const ScannerPage(), ), - const LabelsPage(), + MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + BlocProvider( + create: (context) => LabelCubit(context.read()), + ), + ], + child: const LabelsPage(), + ), + BlocProvider.value( + value: _inboxCubit, + child: const InboxPage(), + ), + // const SettingsPage(), ]; return MultiBlocListener( listeners: [ @@ -237,8 +278,6 @@ class _HomePageState extends State { builder: (context, sizingInformation) { if (!sizingInformation.isMobile) { return Scaffold( - key: rootScaffoldKey, - drawer: const AppDrawer(), body: Row( children: [ NavigationRail( @@ -258,15 +297,14 @@ class _HomePageState extends State { ); } return Scaffold( - key: rootScaffoldKey, bottomNavigationBar: NavigationBar( + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, elevation: 4.0, selectedIndex: _currentIndex, onDestinationSelected: _onNavigationChanged, destinations: destinations.map((e) => e.toNavigationDestination()).toList(), ), - drawer: const AppDrawer(), body: routes[_currentIndex], ); }, @@ -282,16 +320,10 @@ class _HomePageState extends State { void _initializeData(BuildContext context) { try { - context.read>().findAll(); - context - .read>() - .findAll(); - context - .read>() - .findAll(); - context - .read>() - .findAll(); + context.read>().findAll(); + context.read>().findAll(); + context.read>().findAll(); + context.read>().findAll(); context.read().findAll(); context.read().updateInformtion(); } on PaperlessServerException catch (error, stackTrace) { diff --git a/lib/features/home/view/route_description.dart b/lib/features/home/view/route_description.dart index ee4c36a..367c41a 100644 --- a/lib/features/home/view/route_description.dart +++ b/lib/features/home/view/route_description.dart @@ -4,18 +4,20 @@ class RouteDescription { final String label; final Icon icon; final Icon selectedIcon; + final Widget Function(Widget icon)? badgeBuilder; RouteDescription({ required this.label, required this.icon, required this.selectedIcon, + this.badgeBuilder, }); NavigationDestination toNavigationDestination() { return NavigationDestination( label: label, - icon: icon, - selectedIcon: selectedIcon, + icon: badgeBuilder?.call(icon) ?? icon, + selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, ); } @@ -30,8 +32,8 @@ class RouteDescription { BottomNavigationBarItem toBottomNavigationBarItem() { return BottomNavigationBarItem( label: label, - icon: icon, - activeIcon: selectedIcon, + icon: badgeBuilder?.call(icon) ?? icon, + activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, ); } } diff --git a/lib/features/home/view/widget/_app_drawer.dart b/lib/features/home/view/widget/_app_drawer.dart new file mode 100644 index 0000000..8f2001b --- /dev/null +++ b/lib/features/home/view/widget/_app_drawer.dart @@ -0,0 +1,320 @@ +// import 'package:flutter/material.dart'; +// import 'package:flutter_bloc/flutter_bloc.dart'; +// import 'package:hydrated_bloc/hydrated_bloc.dart'; +// import 'package:package_info_plus/package_info_plus.dart'; +// import 'package:paperless_api/paperless_api.dart'; +// import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +// import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart'; +// import 'package:paperless_mobile/core/repository/label_repository.dart'; +// import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; +// import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; +// import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; +// import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; +// import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; +// import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; +// import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +// import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; +// import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; +// import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; +// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +// import 'package:paperless_mobile/features/settings/view/settings_page.dart'; +// import 'package:paperless_mobile/generated/l10n.dart'; +// import 'package:paperless_mobile/helpers/message_helpers.dart'; +// import 'package:paperless_mobile/constants.dart'; +// import 'package:url_launcher/link.dart'; +// import 'package:url_launcher/url_launcher_string.dart'; + +// class AppDrawer extends StatefulWidget { +// final VoidCallback? afterInboxClosed; + +// const AppDrawer({Key? key, this.afterInboxClosed}) : super(key: key); + +// @override +// State createState() => _AppDrawerState(); +// } + +// // enum NavigationDestinations { +// // inbox, +// // settings, +// // reportBug, +// // about, +// // logout; +// // } + +// class _AppDrawerState extends State { +// @override +// void initState() { +// super.initState(); +// } + +// @override +// Widget build(BuildContext context) { +// final listtTileShape = RoundedRectangleBorder( +// borderRadius: BorderRadius.circular(32), +// ); +// // return NavigationDrawer( +// // selectedIndex: -1, +// // children: [ +// // Text( +// // "", +// // style: Theme.of(context).textTheme.titleSmall, +// // ).padded(16), +// // NavigationDrawerDestination( +// // icon: const Icon(Icons.inbox), +// // label: Text(S.of(context).bottomNavInboxPageLabel), +// // ), +// // NavigationDrawerDestination( +// // icon: const Icon(Icons.settings), +// // label: Text(S.of(context).appDrawerSettingsLabel), +// // ), +// // const Divider( +// // indent: 16, +// // ), +// // NavigationDrawerDestination( +// // icon: const Icon(Icons.bug_report), +// // label: Text(S.of(context).appDrawerReportBugLabel), +// // ), +// // NavigationDrawerDestination( +// // icon: const Icon(Icons.info_outline), +// // label: Text(S.of(context).appDrawerAboutLabel), +// // ), +// // ], +// // onDestinationSelected: (idx) { +// // final val = NavigationDestinations.values[idx - 1]; +// // switch (val) { +// // case NavigationDestinations.inbox: +// // _onOpenInbox(); +// // break; +// // case NavigationDestinations.settings: +// // _onOpenSettings(); +// // break; +// // case NavigationDestinations.reportBug: +// // launchUrlString( +// // 'https://github.com/astubenbord/paperless-mobile/issues/new', +// // ); +// // break; +// // case NavigationDestinations.about: +// // _onShowAboutDialog(); +// // break; +// // case NavigationDestinations.logout: +// // _onLogout(); +// // break; +// // } +// // }, +// // ); +// return SafeArea( +// top: true, +// child: ClipRRect( +// borderRadius: const BorderRadius.only( +// topRight: Radius.circular(16.0), +// bottomRight: Radius.circular(16.0), +// ), +// child: Drawer( +// shape: const RoundedRectangleBorder( +// borderRadius: BorderRadius.only( +// topRight: Radius.circular(16.0), +// bottomRight: Radius.circular(16.0), +// ), +// ), +// child: ListView( +// children: [ +// DrawerHeader( +// decoration: BoxDecoration( +// color: Theme.of(context).colorScheme.secondaryContainer, +// ), +// padding: const EdgeInsets.only( +// top: 8, +// left: 8, +// bottom: 0, +// right: 8, +// ), +// child: Column( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// children: [ +// Image.asset( +// 'assets/logos/paperless_logo_white.png', +// height: 32, +// width: 32, +// color: +// Theme.of(context).colorScheme.onPrimaryContainer, +// ).paddedOnly(right: 8.0), +// Text( +// S.of(context).appTitleText, +// style: Theme.of(context) +// .textTheme +// .headlineSmall +// ?.copyWith( +// color: Theme.of(context) +// .colorScheme +// .onPrimaryContainer, +// ), +// ), +// ], +// ), +// Align( +// alignment: Alignment.bottomRight, +// child: BlocBuilder( +// builder: (context, state) { +// if (!state.isLoaded) { +// return Container(); +// } +// final info = state.information!; +// return Column( +// crossAxisAlignment: CrossAxisAlignment.end, +// children: [ +// ListTile( +// contentPadding: EdgeInsets.zero, +// dense: true, +// title: Text( +// S.of(context).appDrawerHeaderLoggedInAsText + +// (info.username ?? '?'), +// style: Theme.of(context).textTheme.bodyMedium, +// overflow: TextOverflow.ellipsis, +// textAlign: TextAlign.end, +// maxLines: 1, +// ), +// subtitle: Column( +// crossAxisAlignment: CrossAxisAlignment.end, +// children: [ +// Text( +// state.information!.host ?? '', +// style: Theme.of(context) +// .textTheme +// .bodyMedium, +// overflow: TextOverflow.ellipsis, +// textAlign: TextAlign.end, +// maxLines: 1, +// ), +// Text( +// '${S.of(context).serverInformationPaperlessVersionText} ${info.version} (API v${info.apiVersion})', +// style: +// Theme.of(context).textTheme.bodySmall, +// overflow: TextOverflow.ellipsis, +// textAlign: TextAlign.end, +// maxLines: 1, +// ), +// ], +// ), +// isThreeLine: true, +// ), +// ], +// ); +// }, +// ), +// ), +// ], +// ), +// ), +// ...[ +// ListTile( +// title: Text(S.of(context).bottomNavInboxPageLabel), +// leading: const Icon(Icons.inbox), +// onTap: () => _onOpenInbox(), +// shape: listtTileShape, +// ), +// ListTile( +// leading: const Icon(Icons.settings), +// shape: listtTileShape, +// title: Text( +// S.of(context).appDrawerSettingsLabel, +// ), +// onTap: () => Navigator.of(context).push( +// MaterialPageRoute( +// builder: (context) => BlocProvider.value( +// value: context.read(), +// child: const SettingsPage(), +// ), +// ), +// ), +// ), +// const Divider( +// indent: 16, +// endIndent: 16, +// ), +// ListTile( +// leading: const Icon(Icons.bug_report), +// title: Text(S.of(context).appDrawerReportBugLabel), +// onTap: () { +// launchUrlString( +// 'https://github.com/astubenbord/paperless-mobile/issues/new'); +// }, +// shape: listtTileShape, +// ), +// ListTile( +// title: Text(S.of(context).appDrawerAboutLabel), +// leading: Icon(Icons.info_outline_rounded), +// onTap: _onShowAboutDialog, +// shape: listtTileShape, +// ), +// ListTile( +// leading: const Icon(Icons.logout), +// title: Text(S.of(context).appDrawerLogoutLabel), +// shape: listtTileShape, +// onTap: () { +// _onLogout(); +// }, +// ) +// ], +// ], +// ), +// ), +// ), +// ); +// } + +// void _onLogout() async { +// try { +// await context.read().logout(); +// await context.read().clear(); +// await context.read>().clear(); +// await context +// .read>() +// .clear(); +// await context +// .read>() +// .clear(); +// await context +// .read>() +// .clear(); +// await context.read().clear(); +// await HydratedBloc.storage.clear(); +// } on PaperlessServerException catch (error, stackTrace) { +// showErrorMessage(context, error, stackTrace); +// } +// } + +// Future _onOpenInbox() async { +// await Navigator.of(context).push( +// MaterialPageRoute( +// builder: (_) => LabelRepositoriesProvider( +// child: BlocProvider( +// create: (context) => InboxCubit( +// context.read(), +// context.read(), +// context.read(), +// context.read(), +// )..initializeInbox(), +// child: const InboxPage(), +// ), +// ), +// ), +// ); +// widget.afterInboxClosed?.call(); +// } + +// void _onOpenSettings() { +// Navigator.of(context).push( +// MaterialPageRoute( +// builder: (context) => BlocProvider.value( +// value: context.read(), +// child: const SettingsPage(), +// ), +// ), +// ); +// } +// void _onShowAboutDialog() {} +// } diff --git a/lib/features/home/view/widget/app_drawer.dart b/lib/features/home/view/widget/app_drawer.dart deleted file mode 100644 index 4b87940..0000000 --- a/lib/features/home/view/widget/app_drawer.dart +++ /dev/null @@ -1,386 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; -import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; -import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; -import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; -import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/view/settings_page.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; -import 'package:url_launcher/link.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class AppDrawer extends StatefulWidget { - final VoidCallback? afterInboxClosed; - - const AppDrawer({Key? key, this.afterInboxClosed}) : super(key: key); - - @override - State createState() => _AppDrawerState(); -} - -// enum NavigationDestinations { -// inbox, -// settings, -// reportBug, -// about, -// logout; -// } - -class _AppDrawerState extends State { - late final Future _packageInfo; - - @override - void initState() { - super.initState(); - _packageInfo = PackageInfo.fromPlatform(); - } - - @override - Widget build(BuildContext context) { - final listtTileShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(32), - ); - // return NavigationDrawer( - // selectedIndex: -1, - // children: [ - // Text( - // "", - // style: Theme.of(context).textTheme.titleSmall, - // ).padded(16), - // NavigationDrawerDestination( - // icon: const Icon(Icons.inbox), - // label: Text(S.of(context).bottomNavInboxPageLabel), - // ), - // NavigationDrawerDestination( - // icon: const Icon(Icons.settings), - // label: Text(S.of(context).appDrawerSettingsLabel), - // ), - // const Divider( - // indent: 16, - // ), - // NavigationDrawerDestination( - // icon: const Icon(Icons.bug_report), - // label: Text(S.of(context).appDrawerReportBugLabel), - // ), - // NavigationDrawerDestination( - // icon: const Icon(Icons.info_outline), - // label: Text(S.of(context).appDrawerAboutLabel), - // ), - // ], - // onDestinationSelected: (idx) { - // final val = NavigationDestinations.values[idx - 1]; - // switch (val) { - // case NavigationDestinations.inbox: - // _onOpenInbox(); - // break; - // case NavigationDestinations.settings: - // _onOpenSettings(); - // break; - // case NavigationDestinations.reportBug: - // launchUrlString( - // 'https://github.com/astubenbord/paperless-mobile/issues/new', - // ); - // break; - // case NavigationDestinations.about: - // _onShowAboutDialog(); - // break; - // case NavigationDestinations.logout: - // _onLogout(); - // break; - // } - // }, - // ); - return SafeArea( - top: true, - child: ClipRRect( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(16.0), - ), - child: Drawer( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(16.0), - ), - ), - child: Theme( - data: Theme.of(context).copyWith( - listTileTheme: ListTileThemeData( - tileColor: Colors.transparent, - ), - ), - child: ListView( - children: [ - DrawerHeader( - padding: const EdgeInsets.only( - top: 8, - left: 8, - bottom: 0, - right: 8, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Image.asset( - 'assets/logos/paperless_logo_white.png', - height: 32, - width: 32, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ).paddedOnly(right: 8.0), - Text( - S.of(context).appTitleText, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ], - ), - Align( - alignment: Alignment.bottomRight, - child: BlocBuilder( - builder: (context, state) { - if (!state.isLoaded) { - return Container(); - } - final info = state.information!; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text( - S - .of(context) - .appDrawerHeaderLoggedInAsText + - (info.username ?? '?'), - style: - Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - state.information!.host ?? '', - style: Theme.of(context) - .textTheme - .bodyMedium, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - Text( - '${S.of(context).serverInformationPaperlessVersionText} ${info.version} (API v${info.apiVersion})', - style: Theme.of(context) - .textTheme - .bodySmall, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - maxLines: 1, - ), - ], - ), - isThreeLine: true, - ), - ], - ); - }, - ), - ), - ], - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - ), - ), - ...[ - ListTile( - title: Text(S.of(context).bottomNavInboxPageLabel), - leading: const Icon(Icons.inbox), - onTap: () => _onOpenInbox(), - shape: listtTileShape, - ), - ListTile( - leading: const Icon(Icons.settings), - shape: listtTileShape, - title: Text( - S.of(context).appDrawerSettingsLabel, - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: const SettingsPage(), - ), - ), - ), - ), - const Divider( - indent: 16, - endIndent: 16, - ), - ListTile( - leading: const Icon(Icons.bug_report), - title: Text(S.of(context).appDrawerReportBugLabel), - onTap: () { - launchUrlString( - 'https://github.com/astubenbord/paperless-mobile/issues/new'); - }, - shape: listtTileShape, - ), - ListTile( - title: Text(S.of(context).appDrawerAboutLabel), - leading: Icon(Icons.info_outline_rounded), - onTap: _onShowAboutDialog, - shape: listtTileShape, - ), - ListTile( - leading: const Icon(Icons.logout), - title: Text(S.of(context).appDrawerLogoutLabel), - shape: listtTileShape, - onTap: () { - _onLogout(); - }, - ) - ], - ], - ), - ), - ), - ), - ); - } - - void _onLogout() async { - try { - await context.read().logout(); - await context.read().clear(); - await context.read().clear(); - await context.read>().clear(); - await context - .read>() - .clear(); - await context - .read>() - .clear(); - await context - .read>() - .clear(); - await context.read().clear(); - await HydratedBloc.storage.clear(); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - - Future _onOpenInbox() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LabelRepositoriesProvider( - child: BlocProvider( - create: (context) => InboxCubit( - context.read(), - context.read(), - context.read(), - context.read(), - )..initializeInbox(), - child: const InboxPage(), - ), - ), - ), - ); - widget.afterInboxClosed?.call(); - } - - void _onOpenSettings() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: const SettingsPage(), - ), - ), - ); - } - - Link _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.') - ], - ), - ); - } - - Future _onShowAboutDialog() async { - final snapshot = await _packageInfo; - showAboutDialog( - context: context, - applicationIcon: const ImageIcon( - AssetImage('assets/logos/paperless_logo_green.png'), - ), - applicationName: 'Paperless Mobile', - applicationVersion: snapshot.version + '+' + snapshot.buildNumber, - children: [ - Text(S.of(context).aboutDialogDevelopedByText('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(), - ], - ); - } -} diff --git a/lib/features/home/view/widget/bottom_navigation_bar.dart b/lib/features/home/view/widget/bottom_navigation_bar.dart deleted file mode 100644 index 2b9a27a..0000000 --- a/lib/features/home/view/widget/bottom_navigation_bar.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; - -class BottomNavBar extends StatelessWidget { - final int selectedIndex; - final void Function(int) onNavigationChanged; - - const BottomNavBar( - {Key? key, - required this.selectedIndex, - required this.onNavigationChanged}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return NavigationBar( - elevation: 4.0, - onDestinationSelected: onNavigationChanged, - selectedIndex: selectedIndex, - destinations: [ - NavigationDestination( - icon: const Icon(Icons.description_outlined), - selectedIcon: Icon( - Icons.description, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context).bottomNavDocumentsPageLabel, - ), - NavigationDestination( - icon: const Icon(Icons.document_scanner_outlined), - selectedIcon: Icon( - Icons.document_scanner, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context).bottomNavScannerPageLabel, - ), - NavigationDestination( - icon: const Icon(Icons.sell_outlined), - selectedIcon: Icon( - Icons.sell, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context).bottomNavLabelsPageLabel, - ), - NavigationDestination( - icon: const Icon(Icons.inbox_outlined), - selectedIcon: Icon( - Icons.inbox, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context).bottomNavInboxPageLabel, - ), - NavigationDestination( - icon: const Icon(Icons.settings_outlined), - selectedIcon: Icon( - Icons.settings, - color: Theme.of(context).colorScheme.primary, - ), - label: S.of(context).appDrawerSettingsLabel, - ), - ], - ); - } -} diff --git a/lib/features/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart index 78814d7..a518d57 100644 --- a/lib/features/home/view/widget/verify_identity_page.dart +++ b/lib/features/home/view/widget/verify_identity_page.dart @@ -70,16 +70,10 @@ class VerifyIdentityPage extends StatelessWidget { void _logout(BuildContext context) { context.read().logout(); - context.read>().clear(); - context - .read>() - .clear(); - context - .read>() - .clear(); - context - .read>() - .clear(); + context.read>().clear(); + context.read>().clear(); + context.read>().clear(); + context.read>().clear(); context.read().clear(); HydratedBloc.storage.clear(); } diff --git a/lib/features/inbox/bloc/inbox_cubit.dart b/lib/features/inbox/bloc/inbox_cubit.dart index 22da765..b408f04 100644 --- a/lib/features/inbox/bloc/inbox_cubit.dart +++ b/lib/features/inbox/bloc/inbox_cubit.dart @@ -1,23 +1,25 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_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/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; -class InboxCubit extends HydratedCubit with DocumentsPagingMixin { - final LabelRepository _tagsRepository; - final LabelRepository - _correspondentRepository; - final LabelRepository - _documentTypeRepository; +class InboxCubit extends HydratedCubit with PagedDocumentsMixin { + final LabelRepository _tagsRepository; + final LabelRepository _correspondentRepository; + final LabelRepository _documentTypeRepository; final PaperlessDocumentsApi _documentsApi; + @override + final DocumentChangedNotifier notifier; + + final PaperlessServerStatsApi _statsApi; + final List _subscriptions = []; @override @@ -28,6 +30,8 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { this._documentsApi, this._correspondentRepository, this._documentTypeRepository, + this._statsApi, + this.notifier, ) : super( InboxState( availableCorrespondents: @@ -37,6 +41,21 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { availableTags: _tagsRepository.current?.values ?? {}, ), ) { + notifier.subscribe( + this, + onDeleted: remove, + onUpdated: (document) { + if (document.tags + .toSet() + .intersection(state.inboxTags.toSet()) + .isEmpty) { + remove(document); + emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); + } else { + replace(document); + } + }, + ); _subscriptions.add( _tagsRepository.values.listen((event) { if (event?.hasLoaded ?? false) { @@ -60,12 +79,35 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { } }), ); + + refreshItemsInInboxCount(false); + loadInbox(); + + Timer.periodic(const Duration(seconds: 5), (timer) { + if (isClosed) { + timer.cancel(); + } + refreshItemsInInboxCount(); + }); + } + + void refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { + final stats = await _statsApi.getServerStatistics(); + + if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { + loadInbox(); + } + emit( + state.copyWith( + itemsInInboxCount: stats.documentsInInbox, + ), + ); } /// /// Fetches inbox tag ids and loads the inbox items (documents). /// - Future initializeInbox() async { + Future loadInbox() async { final inboxTags = await _tagsRepository.findAll().then( (tags) => tags.where((t) => t.isInboxTag ?? false).map((t) => t.id!), ); @@ -81,7 +123,7 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { ); } emit(state.copyWith(inboxTags: inboxTags)); - return updateFilter( + updateFilter( filter: DocumentFilter( sortField: SortField.added, tags: IdsTagsQuery.fromIds(inboxTags), @@ -98,10 +140,12 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { document.tags.toSet().intersection(state.inboxTags.toSet()); final updatedTags = {...document.tags}..removeAll(tagsToRemove); - await api.update( + final updatedDocument = await api.update( document.copyWith(tags: updatedTags), ); - await remove(document); + // Remove first so document is not replaced first. + remove(document); + notifier.notifyUpdated(updatedDocument); return tagsToRemove; } @@ -112,10 +156,13 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { DocumentModel document, Iterable removedTags, ) async { - final updatedDoc = document.copyWith( - tags: {...document.tags, ...removedTags}, + final updatedDocument = await _documentsApi.update( + document.copyWith( + tags: {...document.tags, ...removedTags}, + ), ); - await _documentsApi.update(updatedDoc); + notifier.notifyUpdated(updatedDocument); + emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1)); return reload(); } @@ -134,27 +181,19 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { emit(state.copyWith( hasLoaded: true, value: [], + itemsInInboxCount: 0, )); } finally { emit(state.copyWith(isLoading: false)); } } - void replaceUpdatedDocument(DocumentModel document) { - if (document.tags.any((id) => state.inboxTags.contains(id))) { - // If replaced document still has inbox tag assigned: - replace(document); - } else { - // Remove document from inbox. - remove(document); - } - } - Future assignAsn(DocumentModel document) async { if (document.archiveSerialNumber == null) { final int asn = await _documentsApi.findNextAsn(); final updatedDocument = await _documentsApi .update(document.copyWith(archiveSerialNumber: asn)); + replace(updatedDocument); } } @@ -175,9 +214,9 @@ class InboxCubit extends HydratedCubit with DocumentsPagingMixin { @override Future close() { - _subscriptions.forEach((element) { - element.cancel(); - }); + for (var sub in _subscriptions) { + sub.cancel(); + } return super.close(); } } diff --git a/lib/features/inbox/bloc/state/inbox_state.dart b/lib/features/inbox/bloc/state/inbox_state.dart index 186c501..8546bac 100644 --- a/lib/features/inbox/bloc/state/inbox_state.dart +++ b/lib/features/inbox/bloc/state/inbox_state.dart @@ -1,13 +1,11 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; part 'inbox_state.g.dart'; -@JsonSerializable( - ignoreUnannotated: true, -) -class InboxState extends DocumentsPagedState { +@JsonSerializable(ignoreUnannotated: true) +class InboxState extends PagedDocumentsState { final Iterable inboxTags; final Map availableTags; @@ -16,6 +14,8 @@ class InboxState extends DocumentsPagedState { final Map availableCorrespondents; + final int itemsInInboxCount; + @JsonKey() final bool isHintAcknowledged; @@ -29,6 +29,7 @@ class InboxState extends DocumentsPagedState { this.availableTags = const {}, this.availableDocumentTypes = const {}, this.availableCorrespondents = const {}, + this.itemsInInboxCount = 0, }); @override @@ -43,6 +44,7 @@ class InboxState extends DocumentsPagedState { availableTags, availableDocumentTypes, availableCorrespondents, + itemsInInboxCount, ]; InboxState copyWith({ @@ -56,6 +58,7 @@ class InboxState extends DocumentsPagedState { Map? availableCorrespondents, Map? availableDocumentTypes, Map? suggestions, + int? itemsInInboxCount, }) { return InboxState( hasLoaded: hasLoaded ?? super.hasLoaded, @@ -69,6 +72,7 @@ class InboxState extends DocumentsPagedState { availableDocumentTypes ?? this.availableDocumentTypes, availableTags: availableTags ?? this.availableTags, filter: filter ?? super.filter, + itemsInInboxCount: itemsInInboxCount ?? this.itemsInInboxCount, ); } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index a41cdc1..92fbc1d 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -1,10 +1,11 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; +import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -12,8 +13,9 @@ import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget.dart'; import 'package:paperless_mobile/features/inbox/view/widgets/inbox_item.dart'; +import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; class InboxPage extends StatefulWidget { const InboxPage({super.key}); @@ -29,6 +31,7 @@ class _InboxPageState extends State { @override void initState() { super.initState(); + context.read().loadInbox(); _scrollController.addListener(_listenForLoadNewData); } @@ -54,49 +57,14 @@ class _InboxPageState extends State { @override Widget build(BuildContext context) { - const _progressBarHeight = 4.0; + final safeAreaPadding = MediaQuery.of(context).padding; + final availableHeight = MediaQuery.of(context).size.height - + kToolbarHeight - + kBottomNavigationBarHeight - + safeAreaPadding.top - + safeAreaPadding.bottom; return Scaffold( - appBar: PreferredSize( - preferredSize: - const Size.fromHeight(kToolbarHeight + _progressBarHeight), - child: BlocBuilder( - builder: (context, state) { - return AppBar( - title: Text(S.of(context).bottomNavInboxPageLabel), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - actions: [ - if (state.hasLoaded) - Align( - alignment: Alignment.centerRight, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: ColoredBox( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Text( - state.value.isEmpty - ? '0' - : '${state.value.first.count} ' + - S.of(context).inboxPageUnseenText, - textAlign: TextAlign.start, - style: Theme.of(context).textTheme.bodySmall, - ).paddedSymmetrically(horizontal: 4.0), - ), - ), - ).paddedSymmetrically(horizontal: 8) - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(4), - child: state.isLoading && state.hasLoaded - ? const LinearProgressIndicator() - : const SizedBox(height: _progressBarHeight), - ), - ); - }, - ), - ), + drawer: const AppDrawer(), floatingActionButton: BlocBuilder( builder: (context, state) { if (!state.hasLoaded || state.documents.isEmpty) { @@ -116,86 +84,100 @@ class _InboxPageState extends State { ), body: BlocBuilder( builder: (context, state) { - if (!state.hasLoaded) { - return const DocumentsListLoadingWidget(); - } - - if (state.documents.isEmpty) { - return InboxEmptyWidget( - emptyStateRefreshIndicatorKey: _emptyStateRefreshIndicatorKey, - ); - } - - // Build a list of slivers alternating between SliverToBoxAdapter - // (group header) and a SliverList (inbox items). - final List slivers = _groupByDate(state.documents) - .entries - .map( - (entry) => [ - SliverToBoxAdapter( - child: Align( - alignment: Alignment.centerLeft, - child: ClipRRect( - borderRadius: BorderRadius.circular(32.0), - child: Text( - entry.key, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ).padded(), - ), - ).paddedOnly(top: 8.0), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: entry.value.length, - (context, index) { - if (index < entry.value.length - 1) { - return Column( - children: [ - _buildListItem( + return SafeArea( + top: true, + child: Builder( + builder: (context) { + // Build a list of slivers alternating between SliverToBoxAdapter + // (group header) and a SliverList (inbox items). + final List slivers = _groupByDate(state.documents) + .entries + .map( + (entry) => [ + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerLeft, + child: ClipRRect( + borderRadius: BorderRadius.circular(32.0), + child: Text( + entry.key, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ).padded(), + ), + ).paddedOnly(top: 8.0), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + childCount: entry.value.length, + (context, index) { + if (index < entry.value.length - 1) { + return Column( + children: [ + _buildListItem( + entry.value[index], + ), + const Divider( + indent: 16, + endIndent: 16, + ), + ], + ); + } + return _buildListItem( entry.value[index], - ), - const Divider( - indent: 16, - endIndent: 16, - ), - ], - ); - } - return _buildListItem( - entry.value[index], - ); - }, - ), - ), - ], - ) - .flattened - .toList() - ..add(const SliverToBoxAdapter(child: SizedBox(height: 78))); + ); + }, + ), + ), + ], + ) + .flattened + .toList() + ..add(const SliverToBoxAdapter(child: SizedBox(height: 78))); + // edgeOffset: kToolbarHeight, - return RefreshIndicator( - onRefresh: () => context.read().initializeInbox(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( + return RefreshIndicator( + edgeOffset: kToolbarHeight, + onRefresh: context.read().reload, child: CustomScrollView( + physics: state.documents.isEmpty + ? const NeverScrollableScrollPhysics() + : const AlwaysScrollableScrollPhysics(), controller: _scrollController, slivers: [ - SliverToBoxAdapter( - child: HintCard( - show: !state.isHintAcknowledged, - hintText: S.of(context).inboxPageUsageHintText, - onHintAcknowledged: () => - context.read().acknowledgeHint(), - ), + SearchAppBar( + hintText: S.of(context).documentSearchSearchDocuments, + onOpenSearch: showDocumentSearchPage, ), + if (state.documents.isEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: availableHeight, + child: Center( + child: InboxEmptyWidget( + emptyStateRefreshIndicatorKey: + _emptyStateRefreshIndicatorKey, + ), + ), + ), + ) + else if (!state.hasLoaded) + DocumentsListLoadingWidget() + else + SliverToBoxAdapter( + child: HintCard( + show: !state.isHintAcknowledged, + hintText: S.of(context).inboxPageUsageHintText, + onHintAcknowledged: () => + context.read().acknowledgeHint(), + ), + ), ...slivers, ], ), - ), - ], + ); + }, ), ); }, @@ -223,12 +205,7 @@ class _InboxPageState extends State { ).padded(), confirmDismiss: (_) => _onItemDismissed(doc), key: UniqueKey(), - child: InboxItem( - document: doc, - onDocumentUpdated: (document) { - context.read().replaceUpdatedDocument(document); - }, - ), + child: InboxItem(document: doc), ); } diff --git a/lib/features/inbox/view/widgets/inbox_empty_widget.dart b/lib/features/inbox/view/widgets/inbox_empty_widget.dart index bf79d2a..b23fce3 100644 --- a/lib/features/inbox/view/widgets/inbox_empty_widget.dart +++ b/lib/features/inbox/view/widgets/inbox_empty_widget.dart @@ -16,7 +16,7 @@ class InboxEmptyWidget extends StatelessWidget { Widget build(BuildContext context) { return RefreshIndicator( key: _emptyStateRefreshIndicatorKey, - onRefresh: () => context.read().initializeInbox(), + onRefresh: () => context.read().loadInbox(), child: Center( child: Column( mainAxisSize: MainAxisSize.max, diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index 2367594..59b4cab 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -1,29 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; -import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; class InboxItem extends StatefulWidget { static const _a4AspectRatio = 1 / 1.4142; - final void Function(DocumentModel model) onDocumentUpdated; final DocumentModel document; const InboxItem({ super.key, required this.document, - required this.onDocumentUpdated, }); @override @@ -40,25 +34,14 @@ class _InboxItemState extends State { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () async { - final returnedDocument = await Navigator.push( + Navigator.pushNamed( context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - widget.document, - ), - child: const LabelRepositoriesProvider( - child: DocumentDetailsPage( - isLabelClickable: false, - ), - ), - ), + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: widget.document, + isLabelClickable: false, ), ); - if (returnedDocument != null) { - widget.onDocumentUpdated(returnedDocument); - } }, child: SizedBox( height: 200, @@ -111,12 +94,12 @@ class _InboxItemState extends State { ); final actions = [ _buildAssignAsnAction(chipShape, context), - const SizedBox(width: 4.0), + const SizedBox(width: 8.0), ColoredChipWrapper( child: ActionChip( avatar: const Icon(Icons.delete_outline), shape: chipShape, - label: const Text("Delete document"), + label: Text(S.of(context).inboxActionDeleteDocument), onPressed: () async { final shouldDelete = await showDialog( context: context, @@ -131,6 +114,7 @@ class _InboxItemState extends State { ), ), ]; + // return FutureBuilder( // future: _fieldSuggestions, // builder: (context, snapshot) { @@ -158,12 +142,14 @@ class _InboxItemState extends State { mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.bolt_outlined), - SizedBox( - width: 40, + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 50, + ), child: Text( S.of(context).inboxPageQuickActionsLabel, textAlign: TextAlign.center, - maxLines: 2, + maxLines: 3, style: Theme.of(context).textTheme.labelSmall, ), ), @@ -206,7 +192,7 @@ class _InboxItemState extends State { ? Text( '${S.of(context).documentArchiveSerialNumberPropertyShortLabel} #${widget.document.archiveSerialNumber}', ) - : const Text("Assign ASN"), + : Text(S.of(context).inboxActionAssignAsn), onPressed: !hasAsn ? () { setState(() { @@ -240,7 +226,7 @@ class _InboxItemState extends State { Icons.description_outlined, size: Theme.of(context).textTheme.bodyMedium?.fontSize, ), - LabelText( + LabelText( id: widget.document.documentType, style: Theme.of(context).textTheme.bodyMedium, placeholder: "-", @@ -254,7 +240,7 @@ class _InboxItemState extends State { Icons.person_outline, size: Theme.of(context).textTheme.bodyMedium?.fontSize, ), - LabelText( + LabelText( id: widget.document.correspondent, style: Theme.of(context).textTheme.bodyMedium, placeholder: "-", diff --git a/lib/features/labels/bloc/label_cubit.dart b/lib/features/labels/bloc/label_cubit.dart index 383900b..e1136b7 100644 --- a/lib/features/labels/bloc/label_cubit.dart +++ b/lib/features/labels/bloc/label_cubit.dart @@ -3,15 +3,14 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; class LabelCubit extends Cubit> { - final LabelRepository _repository; + final LabelRepository _repository; late StreamSubscription _subscription; - LabelCubit(LabelRepository repository) + LabelCubit(LabelRepository repository) : _repository = repository, super(LabelState( isLoaded: repository.isInitialized, @@ -22,7 +21,8 @@ class LabelCubit extends Cubit> { if (event == null) { emit(LabelState()); } - emit(LabelState(isLoaded: true, labels: event!.values)); + emit( + LabelState(isLoaded: event!.hasLoaded, labels: event.values ?? {})); }, ); } diff --git a/lib/features/labels/bloc/providers/correspondent_bloc_provider.dart b/lib/features/labels/bloc/providers/correspondent_bloc_provider.dart index 7a5e9d0..ffbf773 100644 --- a/lib/features/labels/bloc/providers/correspondent_bloc_provider.dart +++ b/lib/features/labels/bloc/providers/correspondent_bloc_provider.dart @@ -7,14 +7,16 @@ import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; class CorrespondentBlocProvider extends StatelessWidget { final Widget child; - const CorrespondentBlocProvider({super.key, required this.child}); + const CorrespondentBlocProvider({ + super.key, + required this.child, + }); @override Widget build(BuildContext context) { return BlocProvider( create: (context) => LabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), child: child, ); diff --git a/lib/features/labels/bloc/providers/document_type_bloc_provider.dart b/lib/features/labels/bloc/providers/document_type_bloc_provider.dart index 3aa129f..6ebcd14 100644 --- a/lib/features/labels/bloc/providers/document_type_bloc_provider.dart +++ b/lib/features/labels/bloc/providers/document_type_bloc_provider.dart @@ -13,8 +13,7 @@ class DocumentTypeBlocProvider extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => LabelCubit( - context - .read>(), + context.read>(), ), child: child, ); diff --git a/lib/features/labels/bloc/providers/labels_bloc_provider.dart b/lib/features/labels/bloc/providers/labels_bloc_provider.dart index 1ec58c2..d4a90e2 100644 --- a/lib/features/labels/bloc/providers/labels_bloc_provider.dart +++ b/lib/features/labels/bloc/providers/labels_bloc_provider.dart @@ -18,25 +18,22 @@ class LabelsBlocProvider extends StatelessWidget { providers: [ BlocProvider>( create: (context) => LabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), ), BlocProvider>( create: (context) => LabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), ), BlocProvider>( create: (context) => LabelCubit( - context.read< - LabelRepository>(), + context.read>(), ), ), BlocProvider>( create: (context) => LabelCubit( - context.read>(), + context.read>(), ), ), ], diff --git a/lib/features/labels/bloc/providers/storage_path_bloc_provider.dart b/lib/features/labels/bloc/providers/storage_path_bloc_provider.dart index 1c03ee4..646c6ad 100644 --- a/lib/features/labels/bloc/providers/storage_path_bloc_provider.dart +++ b/lib/features/labels/bloc/providers/storage_path_bloc_provider.dart @@ -13,8 +13,7 @@ class StoragePathBlocProvider extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => LabelCubit( - context - .read>(), + context.read>(), ), child: child, ); diff --git a/lib/features/labels/bloc/providers/tag_bloc_provider.dart b/lib/features/labels/bloc/providers/tag_bloc_provider.dart index fc36546..4368075 100644 --- a/lib/features/labels/bloc/providers/tag_bloc_provider.dart +++ b/lib/features/labels/bloc/providers/tag_bloc_provider.dart @@ -13,7 +13,7 @@ class TagBlocProvider extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => LabelCubit( - context.read>(), + context.read>(), ), child: child, ); 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 c1e2a4f..0244dad 100644 --- a/lib/features/labels/tags/view/widgets/tags_form_field.dart +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -241,8 +241,7 @@ class _TagFormFieldState extends State { final Tag? tag = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => - context.read>(), + create: (context) => context.read>(), child: AddTagPage(initialValue: _textEditingController.text), ), ), @@ -266,6 +265,10 @@ class _TagFormFieldState extends State { Widget _buildNotAssignedTag(FormFieldState field) { return ColoredChipWrapper( child: InputChip( + labelPadding: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.all(4), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide.none, label: Text( S.of(context).labelNotAssignedText, ), @@ -288,6 +291,10 @@ class _TagFormFieldState extends State { } return ColoredChipWrapper( child: InputChip( + labelPadding: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.all(4), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide.none, label: Text( tag.name, style: TextStyle( @@ -312,6 +319,10 @@ class _TagFormFieldState extends State { Widget _buildAnyAssignedTag(FormFieldState field) { return ColoredChipWrapper( child: InputChip( + labelPadding: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.all(4), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide.none, label: Text(S.of(context).labelAnyAssignedText), backgroundColor: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.12), diff --git a/lib/features/labels/tags/view/widgets/tags_widget.dart b/lib/features/labels/tags/view/widgets/tags_widget.dart index 49a41b8..63aa04f 100644 --- a/lib/features/labels/tags/view/widgets/tags_widget.dart +++ b/lib/features/labels/tags/view/widgets/tags_widget.dart @@ -51,9 +51,7 @@ class TagsWidget extends StatelessWidget { } else { return SingleChildScrollView( scrollDirection: Axis.horizontal, - child: Row( - children: children, - ), + child: Row(children: children), ); } }, diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 0876dc7..1e5986d 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -8,6 +10,9 @@ import 'package:paperless_mobile/core/repository/state/impl/document_type_reposi import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/core/widgets/offline_banner.dart'; +import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; +import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_cubit.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'; @@ -16,12 +21,13 @@ import 'package:paperless_mobile/features/edit_label/view/impl/edit_corresponden 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/widget/app_drawer.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/correspondent_bloc_provider.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/document_type_bloc_provider.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/storage_path_bloc_provider.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/tag_bloc_provider.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; +import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class LabelsPage extends StatefulWidget { @@ -52,153 +58,224 @@ class _LabelsPageState extends State builder: (context, connectedState) { return Scaffold( drawer: const AppDrawer(), - appBar: AppBar( - title: Text( - [ - S.of(context).labelsPageCorrespondentsTitleText, - S.of(context).labelsPageDocumentTypesTitleText, - S.of(context).labelsPageTagsTitleText, - S.of(context).labelsPageStoragePathTitleText - ][_currentIndex], - ), - actions: [ - IconButton( - onPressed: [ - _openAddCorrespondentPage, - _openAddDocumentTypePage, - _openAddTagPage, - _openAddStoragePathPage, - ][_currentIndex], - icon: const Icon(Icons.add), - ) - ], - bottom: PreferredSize( - preferredSize: Size.fromHeight( - kToolbarHeight + (!connectedState.isConnected ? 16 : 0)), - child: Column( - children: [ - if (!connectedState.isConnected) const OfflineBanner(), - ColoredBox( - color: Theme.of(context).bottomAppBarColor, - child: TabBar( - indicatorColor: Theme.of(context).colorScheme.primary, - controller: _tabController, - tabs: [ - Tab( - icon: Icon( - Icons.person_outline, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - Tab( - icon: Icon( - Icons.description_outlined, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - Tab( - icon: Icon( - Icons.label_outline, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - Tab( - icon: Icon( - Icons.folder_open, - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ) - ], - ), - ), - ], - ), - ), + floatingActionButton: FloatingActionButton( + onPressed: [ + _openAddCorrespondentPage, + _openAddDocumentTypePage, + _openAddTagPage, + _openAddStoragePathPage, + ][_currentIndex], + child: Icon(Icons.add), ), - body: TabBarView( - controller: _tabController, - children: [ - CorrespondentBlocProvider( - child: LabelTabView( - filterBuilder: (label) => DocumentFilter( - correspondent: IdQueryParameter.fromId(label.id), - pageSize: label.documentCount ?? 0, - ), - onEdit: _openEditCorrespondentPage, - emptyStateActionButtonLabel: S - .of(context) - .labelsPageCorrespondentEmptyStateAddNewLabel, - emptyStateDescription: S - .of(context) - .labelsPageCorrespondentEmptyStateDescriptionText, - onAddNew: _openAddCorrespondentPage, + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + // This widget takes the overlapping behavior of the SliverAppBar, + // and redirects it to the SliverOverlapInjector below. If it is + // missing, then it is possible for the nested "inner" scroll view + // below to end up under the SliverAppBar even when the inner + // scroll view thinks it has not been scrolled. + // This is not necessary if the "headerSliverBuilder" only builds + // widgets that do not overlap the next sliver. + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, ), - ), - DocumentTypeBlocProvider( - child: LabelTabView( - filterBuilder: (label) => DocumentFilter( - documentType: IdQueryParameter.fromId(label.id), - pageSize: label.documentCount ?? 0, + sliver: SearchAppBar( + hintText: S.of(context).documentSearchSearchDocuments, + onOpenSearch: showDocumentSearchPage, + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab( + icon: Icon( + Icons.person_outline, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.description_outlined, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.label_outline, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + Tab( + icon: Icon( + Icons.folder_open, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ], ), - onEdit: _openEditDocumentTypePage, - emptyStateActionButtonLabel: S - .of(context) - .labelsPageDocumentTypeEmptyStateAddNewLabel, - emptyStateDescription: S - .of(context) - .labelsPageDocumentTypeEmptyStateDescriptionText, - onAddNew: _openAddDocumentTypePage, - ), - ), - TagBlocProvider( - child: LabelTabView( - filterBuilder: (label) => DocumentFilter( - tags: IdsTagsQuery.fromIds([label.id!]), - pageSize: label.documentCount ?? 0, - ), - onEdit: _openEditTagPage, - leadingBuilder: (t) => CircleAvatar( - backgroundColor: t.color, - child: t.isInboxTag ?? false - ? Icon( - Icons.inbox, - color: t.textColor, - ) - : null, - ), - emptyStateActionButtonLabel: - S.of(context).labelsPageTagsEmptyStateAddNewLabel, - emptyStateDescription: - S.of(context).labelsPageTagsEmptyStateDescriptionText, - onAddNew: _openAddTagPage, - ), - ), - StoragePathBlocProvider( - child: LabelTabView( - onEdit: _openEditStoragePathPage, - filterBuilder: (label) => DocumentFilter( - storagePath: IdQueryParameter.fromId(label.id), - pageSize: label.documentCount ?? 0, - ), - contentBuilder: (path) => Text(path.path ?? ""), - emptyStateActionButtonLabel: S - .of(context) - .labelsPageStoragePathEmptyStateAddNewLabel, - emptyStateDescription: S - .of(context) - .labelsPageStoragePathEmptyStateDescriptionText, - onAddNew: _openAddStoragePathPage, ), ), ], + body: NotificationListener( + onNotification: (notification) { + final metrics = notification.metrics; + if (metrics.maxScrollExtent == 0) { + return true; + } + final desiredTab = + ((metrics.pixels / metrics.maxScrollExtent) * + (_tabController.length - 1)) + .round(); + + if (metrics.axis == Axis.horizontal && + _currentIndex != desiredTab) { + setState(() => _currentIndex = desiredTab); + } + return true; + }, + child: RefreshIndicator( + edgeOffset: kToolbarHeight + kTextTabBarHeight, + notificationPredicate: (notification) => + connectedState.isConnected, + onRefresh: () => [ + context.read>(), + context.read>(), + context.read>(), + context.read>(), + ][_currentIndex] + .reload(), + child: TabBarView( + controller: _tabController, + children: [ + Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor(context), + ), + LabelTabView( + filterBuilder: (label) => DocumentFilter( + correspondent: + IdQueryParameter.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + onEdit: _openEditCorrespondentPage, + emptyStateActionButtonLabel: S + .of(context) + .labelsPageCorrespondentEmptyStateAddNewLabel, + emptyStateDescription: S + .of(context) + .labelsPageCorrespondentEmptyStateDescriptionText, + onAddNew: _openAddCorrespondentPage, + ), + ], + ); + }, + ), + Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor(context), + ), + LabelTabView( + filterBuilder: (label) => DocumentFilter( + documentType: + IdQueryParameter.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + onEdit: _openEditDocumentTypePage, + emptyStateActionButtonLabel: S + .of(context) + .labelsPageDocumentTypeEmptyStateAddNewLabel, + emptyStateDescription: S + .of(context) + .labelsPageDocumentTypeEmptyStateDescriptionText, + onAddNew: _openAddDocumentTypePage, + ), + ], + ); + }, + ), + Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor(context), + ), + LabelTabView( + filterBuilder: (label) => DocumentFilter( + tags: IdsTagsQuery.fromIds([label.id!]), + pageSize: label.documentCount ?? 0, + ), + onEdit: _openEditTagPage, + leadingBuilder: (t) => CircleAvatar( + backgroundColor: t.color, + child: t.isInboxTag ?? false + ? Icon( + Icons.inbox, + color: t.textColor, + ) + : null, + ), + emptyStateActionButtonLabel: S + .of(context) + .labelsPageTagsEmptyStateAddNewLabel, + emptyStateDescription: S + .of(context) + .labelsPageTagsEmptyStateDescriptionText, + onAddNew: _openAddTagPage, + ), + ], + ); + }, + ), + Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor(context), + ), + LabelTabView( + onEdit: _openEditStoragePathPage, + filterBuilder: (label) => DocumentFilter( + storagePath: + IdQueryParameter.fromId(label.id), + pageSize: label.documentCount ?? 0, + ), + contentBuilder: (path) => Text(path.path ?? ""), + emptyStateActionButtonLabel: S + .of(context) + .labelsPageStoragePathEmptyStateAddNewLabel, + emptyStateDescription: S + .of(context) + .labelsPageStoragePathEmptyStateDescriptionText, + onAddNew: _openAddStoragePathPage, + ), + ], + ); + }, + ), + ], + ), + ), + ), ), ); }, @@ -211,8 +288,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: EditCorrespondentPage(correspondent: correspondent), ), ), @@ -224,8 +300,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: EditDocumentTypePage(documentType: docType), ), ), @@ -237,8 +312,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => - context.read>(), + create: (context) => context.read>(), child: EditTagPage(tag: tag), ), ), @@ -250,8 +324,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context - .read>(), + create: (context) => context.read>(), child: EditStoragePathPage( storagePath: path, ), @@ -265,8 +338,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: const AddCorrespondentPage(), ), ), @@ -278,8 +350,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), + create: (context) => context.read>(), child: const AddDocumentTypePage(), ), ), @@ -291,8 +362,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => - context.read>(), + create: (context) => context.read>(), child: const AddTagPage(), ), ), @@ -304,8 +374,7 @@ class _LabelsPageState extends State context, MaterialPageRoute( builder: (_) => RepositoryProvider( - create: (context) => context - .read>(), + create: (context) => context.read>(), child: const AddStoragePathPage(), ), ), diff --git a/lib/features/labels/view/widgets/label_form_field.dart b/lib/features/labels/view/widgets/label_form_field.dart index a43f18d..024576c 100644 --- a/lib/features/labels/view/widgets/label_form_field.dart +++ b/lib/features/labels/view/widgets/label_form_field.dart @@ -85,7 +85,6 @@ class _LabelFormFieldState extends State> { TextStyle(color: Theme.of(context).disabledColor, fontSize: 18.0), ), ), - getImmediateSuggestions: true, loadingBuilder: (context) => Container(), initialValue: widget.initialValue ?? const IdQueryParameter.unset(), name: widget.name, @@ -108,7 +107,6 @@ class _LabelFormFieldState extends State> { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - style: ListTileStyle.list, ), suggestionsCallback: (pattern) { final List suggestions = widget.labelOptions.entries diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart index 537aa7f..69d7285 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/features/linked_documents_preview/bloc/linked_documents_cubit.dart'; -import 'package:paperless_mobile/features/linked_documents_preview/view/pages/linked_documents_page.dart'; +import 'package:paperless_mobile/features/linked_documents/bloc/linked_documents_cubit.dart'; +import 'package:paperless_mobile/features/linked_documents/view/pages/linked_documents_page.dart'; +import 'package:paperless_mobile/helpers/format_helpers.dart'; class LabelItem extends StatelessWidget { final T label; @@ -37,7 +38,7 @@ class LabelItem extends StatelessWidget { Widget _buildReferencedDocumentsWidget(BuildContext context) { return TextButton.icon( label: const Icon(Icons.link), - icon: Text(_formatDocumentCount(label.documentCount)), + icon: Text(formatMaxCount(label.documentCount)), onPressed: (label.documentCount ?? 0) == 0 ? null : () { @@ -47,8 +48,9 @@ class LabelItem extends StatelessWidget { MaterialPageRoute( builder: (context) => BlocProvider( create: (context) => LinkedDocumentsCubit( - context.read(), filter, + context.read(), + context.read(), ), child: const LinkedDocumentsPage(), ), @@ -57,11 +59,4 @@ class LabelItem extends StatelessWidget { }, ); } - - String _formatDocumentCount(int? count) { - if ((count ?? 0) > 99) { - return "99+"; - } - return (count ?? 0).toString().padLeft(3); - } } diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index 76133a6..45c68c2 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -37,59 +37,65 @@ class LabelTabView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivityState) { - return BlocBuilder, LabelState>( - builder: (context, state) { - if (!state.isLoaded && !connectivityState.isConnected) { - return const OfflineWidget(); - } - final labels = state.labels.values.toList()..sort(); - if (labels.isEmpty) { - return Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - emptyStateDescription, - textAlign: TextAlign.center, + return BlocProvider( + create: (context) => LabelCubit( + context.read(), + ), + child: BlocBuilder( + builder: (context, connectivityState) { + return BlocBuilder, LabelState>( + builder: (context, state) { + if (!state.isLoaded && !connectivityState.isConnected) { + return const OfflineWidget(); + } + final labels = state.labels.values.toList()..sort(); + if (labels.isEmpty) { + return SliverFillRemaining( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + emptyStateDescription, + textAlign: TextAlign.center, + ), + TextButton( + onPressed: onAddNew, + child: Text(emptyStateActionButtonLabel), + ), + ].padded(), ), - TextButton( - onPressed: onAddNew, - child: Text(emptyStateActionButtonLabel), - ), - ].padded(), + ), + ); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final l = labels.elementAt(index); + return LabelItem( + name: l.name, + content: contentBuilder?.call(l) ?? + Text( + translateMatchingAlgorithmName( + context, l.matchingAlgorithm) + + ((l.match?.isNotEmpty ?? false) + ? ": ${l.match}" + : ""), + maxLines: 2, + ), + onOpenEditPage: onEdit, + filterBuilder: filterBuilder, + leading: leadingBuilder?.call(l), + label: l, + ); + }, + childCount: labels.length, ), ); - } - return RefreshIndicator( - onRefresh: context.read>().reload, - notificationPredicate: (notification) => - connectivityState.isConnected, - child: ListView( - children: labels - .map((l) => LabelItem( - name: l.name, - content: contentBuilder?.call(l) ?? - Text( - translateMatchingAlgorithmName( - context, l.matchingAlgorithm) + - ((l.match?.isNotEmpty ?? false) - ? ": ${l.match}" - : ""), - maxLines: 2, - ), - onOpenEditPage: onEdit, - filterBuilder: filterBuilder, - leading: leadingBuilder?.call(l), - label: l, - )) - .toList(), - ), - ); - }, - ); - }, + }, + ); + }, + ), ); } } diff --git a/lib/features/labels/view/widgets/label_text.dart b/lib/features/labels/view/widgets/label_text.dart index 53b25ff..472fd58 100644 --- a/lib/features/labels/view/widgets/label_text.dart +++ b/lib/features/labels/view/widgets/label_text.dart @@ -2,13 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/state/repository_state.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; -import 'package:paperless_mobile/features/labels/bloc/providers/document_type_bloc_provider.dart'; -class LabelText - extends StatelessWidget { +class LabelText extends StatelessWidget { final int? id; final String placeholder; final TextStyle? style; @@ -24,7 +21,7 @@ class LabelText Widget build(BuildContext context) { return BlocProvider( create: (context) => LabelCubit( - context.read>(), + context.read>(), ), child: BlocBuilder, LabelState>( builder: (context, state) { diff --git a/lib/features/linked_documents/bloc/linked_documents_cubit.dart b/lib/features/linked_documents/bloc/linked_documents_cubit.dart new file mode 100644 index 0000000..c28b368 --- /dev/null +++ b/lib/features/linked_documents/bloc/linked_documents_cubit.dart @@ -0,0 +1,37 @@ +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/features/linked_documents/bloc/state/linked_documents_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; + +class LinkedDocumentsCubit extends Cubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; + + @override + final DocumentChangedNotifier notifier; + + LinkedDocumentsCubit( + DocumentFilter filter, + this.api, + this.notifier, + ) : super(const LinkedDocumentsState()) { + updateFilter(filter: filter); + notifier.subscribe( + this, + onUpdated: replace, + onDeleted: remove, + ); + } + + @override + Future update(DocumentModel document) async { + final updated = await api.update(document); + if (!state.filter.matches(updated)) { + remove(document); + } else { + replace(document); + } + } +} diff --git a/lib/features/linked_documents/bloc/state/linked_documents_state.dart b/lib/features/linked_documents/bloc/state/linked_documents_state.dart new file mode 100644 index 0000000..d72a3e5 --- /dev/null +++ b/lib/features/linked_documents/bloc/state/linked_documents_state.dart @@ -0,0 +1,48 @@ +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; + +class LinkedDocumentsState extends PagedDocumentsState { + const LinkedDocumentsState({ + super.filter, + super.isLoading, + super.hasLoaded, + super.value, + }); + + LinkedDocumentsState copyWith({ + DocumentFilter? filter, + bool? isLoading, + bool? hasLoaded, + List>? value, + }) { + return LinkedDocumentsState( + filter: filter ?? this.filter, + isLoading: isLoading ?? this.isLoading, + hasLoaded: hasLoaded ?? this.hasLoaded, + value: value ?? this.value, + ); + } + + @override + LinkedDocumentsState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + value: value, + filter: filter, + ); + } + + @override + List get props => [ + filter, + isLoading, + hasLoaded, + value, + ]; +} diff --git a/lib/features/linked_documents/view/pages/linked_documents_page.dart b/lib/features/linked_documents/view/pages/linked_documents_page.dart new file mode 100644 index 0000000..2a0ed87 --- /dev/null +++ b/lib/features/linked_documents/view/pages/linked_documents_page.dart @@ -0,0 +1,76 @@ +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/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/features/linked_documents/bloc/linked_documents_cubit.dart'; +import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; + +class LinkedDocumentsPage extends StatefulWidget { + const LinkedDocumentsPage({super.key}); + + @override + State createState() => _LinkedDocumentsPageState(); +} + +class _LinkedDocumentsPageState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_listenForLoadNewData); + } + + void _listenForLoadNewData() async { + final currState = context.read().state; + if (_scrollController.offset >= + _scrollController.position.maxScrollExtent * 0.75 && + !currState.isLoading && + !currState.isLastPageLoaded) { + try { + await context.read().loadMore(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.of(context).linkedDocumentsPageTitle), + ), + body: BlocBuilder( + builder: (context, state) { + return BlocBuilder( + builder: (context, connectivity) { + return DefaultAdaptiveDocumentsView( + scrollController: _scrollController, + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + onTap: (document) { + Navigator.pushNamed( + context, + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: document, + isLabelClickable: false, + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart b/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart deleted file mode 100644 index cc1fd3b..0000000 --- a/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/linked_documents_preview/bloc/state/linked_documents_state.dart'; - -class LinkedDocumentsCubit extends Cubit { - final PaperlessDocumentsApi _api; - - LinkedDocumentsCubit(this._api, DocumentFilter filter) - : super(LinkedDocumentsState(filter: filter)) { - _initialize(); - } - - Future _initialize() async { - final documents = await _api.findAll( - state.filter.copyWith( - pageSize: 100, - ), - ); - emit(LinkedDocumentsState( - isLoaded: true, - documents: documents, - filter: state.filter, - )); - } -} diff --git a/lib/features/linked_documents_preview/bloc/state/linked_documents_state.dart b/lib/features/linked_documents_preview/bloc/state/linked_documents_state.dart deleted file mode 100644 index abb2f4b..0000000 --- a/lib/features/linked_documents_preview/bloc/state/linked_documents_state.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:paperless_api/paperless_api.dart'; - -class LinkedDocumentsState { - final bool isLoaded; - final PagedSearchResult? documents; - final DocumentFilter filter; - - LinkedDocumentsState({ - required this.filter, - this.isLoaded = false, - this.documents, - }); -} diff --git a/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart b/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart deleted file mode 100644 index bdba0c6..0000000 --- a/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart +++ /dev/null @@ -1,76 +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/widgets/documents_list_loading_widget.dart'; -import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; -import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; -import 'package:paperless_mobile/features/linked_documents_preview/bloc/linked_documents_cubit.dart'; -import 'package:paperless_mobile/features/linked_documents_preview/bloc/state/linked_documents_state.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; - -class LinkedDocumentsPage extends StatefulWidget { - const LinkedDocumentsPage({super.key}); - - @override - State createState() => _LinkedDocumentsPageState(); -} - -class _LinkedDocumentsPageState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(S.of(context).linkedDocumentsPageTitle), - ), - body: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - Text( - S.of(context).referencedDocumentsReadOnlyHintText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - if (!state.isLoaded) - Expanded(child: const DocumentsListLoadingWidget()) - else - Expanded( - child: ListView.builder( - itemCount: state.documents?.results.length, - itemBuilder: (context, index) { - return DocumentListItem( - isLabelClickable: false, - document: state.documents!.results.elementAt(index), - onTap: (doc) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - state.documents!.results.elementAt(index), - ), - child: const DocumentDetailsPage( - isLabelClickable: false, - allowEdit: false, - ), - ), - ), - ); - }, - isSelected: false, - isAtLeastOneSelected: false, - isTagSelectedPredicate: (_) => false, - onTagSelected: (int tag) {}, - ); - }, - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/features/login/services/authentication_service.dart b/lib/features/login/services/authentication_service.dart index 84aa8c4..6600125 100644 --- a/lib/features/login/services/authentication_service.dart +++ b/lib/features/login/services/authentication_service.dart @@ -1,12 +1,9 @@ import 'package:local_auth/local_auth.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; class LocalAuthenticationService { - final LocalVault localStore; final LocalAuthentication localAuthentication; LocalAuthenticationService( - this.localStore, this.localAuthentication, ); diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index f49baee..30edca1 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -9,7 +9,8 @@ import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_ 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/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/constants.dart'; import 'widgets/never_scrollable_scroll_behavior.dart'; import 'widgets/login_pages/server_login_page.dart'; 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 408577b..a77605f 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 @@ -6,7 +6,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/constants.dart'; import 'package:permission_handler/permission_handler.dart'; import 'obscured_input_text_form_field.dart'; diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index a30893c..2d85814 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:developer'; +import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart'; @@ -121,7 +122,7 @@ class LocalNotificationService { ) {} void onDidReceiveNotificationResponse(NotificationResponse response) { - log("Received Notification: ${response.payload}"); + debugPrint("Received Notification: ${response.payload}"); if (response.notificationResponseType == NotificationResponseType.selectedNotificationAction) { final action = diff --git a/lib/features/paged_document_view/model/documents_paged_state.dart b/lib/features/paged_document_view/model/paged_documents_state.dart similarity index 81% rename from lib/features/paged_document_view/model/documents_paged_state.dart rename to lib/features/paged_document_view/model/paged_documents_state.dart index dd9920c..e50fe46 100644 --- a/lib/features/paged_document_view/model/documents_paged_state.dart +++ b/lib/features/paged_document_view/model/paged_documents_state.dart @@ -1,13 +1,17 @@ import 'package:equatable/equatable.dart'; import 'package:paperless_api/paperless_api.dart'; -abstract class DocumentsPagedState extends Equatable { +/// +/// Base state for all blocs/cubits using a paged view of documents. +/// [T] is the return type of the API call. +/// +abstract class PagedDocumentsState extends Equatable { final bool hasLoaded; final bool isLoading; final List> value; final DocumentFilter filter; - const DocumentsPagedState({ + const PagedDocumentsState({ this.value = const [], this.hasLoaded = false, this.isLoading = false, @@ -67,4 +71,12 @@ abstract class DocumentsPagedState extends Equatable { List>? value, DocumentFilter? filter, }); + + @override + List get props => [ + filter, + value, + hasLoaded, + isLoading, + ]; } diff --git a/lib/features/paged_document_view/documents_paging_mixin.dart b/lib/features/paged_document_view/paged_documents_mixin.dart similarity index 69% rename from lib/features/paged_document_view/documents_paging_mixin.dart rename to lib/features/paged_document_view/paged_documents_mixin.dart index d012c9b..2800502 100644 --- a/lib/features/paged_document_view/documents_paging_mixin.dart +++ b/lib/features/paged_document_view/paged_documents_mixin.dart @@ -1,15 +1,20 @@ +import 'dart:developer'; + 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 'model/documents_paged_state.dart'; +import 'model/paged_documents_state.dart'; /// -/// Mixin which can be used on cubits which handle documents. This implements all paging and filtering logic. +/// Mixin which can be used on cubits that handle documents. +/// This implements all paging and filtering logic. /// -mixin DocumentsPagingMixin +mixin PagedDocumentsMixin on BlocBase { PaperlessDocumentsApi get api; + DocumentChangedNotifier get notifier; Future loadMore() async { if (state.isLastPageLoaded) { @@ -70,14 +75,18 @@ mixin DocumentsPagingMixin try { final filter = state.filter.copyWith(page: 1); final result = await api.findAll(filter); - emit(state.copyWithPaged( - hasLoaded: true, - value: [result], - isLoading: false, - filter: filter, - )); + if (!isClosed) { + emit(state.copyWithPaged( + hasLoaded: true, + value: [result], + isLoading: false, + filter: filter, + )); + } } finally { - emit(state.copyWithPaged(isLoading: false)); + if (!isClosed) { + emit(state.copyWithPaged(isLoading: false)); + } } } @@ -85,16 +94,10 @@ mixin DocumentsPagingMixin /// Updates a document. If [shouldReload] is false, the updated document will /// replace the currently loaded one, otherwise all documents will be reloaded. /// - Future update( - DocumentModel document, { - bool shouldReload = true, - }) async { + Future update(DocumentModel document) async { final updatedDocument = await api.update(document); - if (shouldReload) { - await reload(); - } else { - replace(updatedDocument); - } + notifier.notifyUpdated(updatedDocument); + // replace(updatedDocument); } /// @@ -104,7 +107,8 @@ mixin DocumentsPagingMixin emit(state.copyWithPaged(isLoading: true)); try { await api.delete(document); - await remove(document); + notifier.notifyDeleted(document); + // remove(document); // Removing deleted now works with the change notifier. } finally { emit(state.copyWithPaged(isLoading: false)); } @@ -114,7 +118,7 @@ mixin DocumentsPagingMixin /// Removes [document] from the currently loaded state. /// Does not delete it from the server! /// - Future remove(DocumentModel document) async { + void remove(DocumentModel document) { final index = state.value.indexWhere( (page) => page.results.any((element) => element.id == document.id), ); @@ -141,23 +145,36 @@ mixin DocumentsPagingMixin /// /// Replaces the document with the same id as [document] from the currently - /// loaded state. + /// loaded state if the document's properties still match the given filter criteria, otherwise removes it. /// Future replace(DocumentModel document) async { - final index = state.value.indexWhere( + final matchesFilterCriteria = state.filter.matches(document); + if (!matchesFilterCriteria) { + return remove(document); + } + final pageIndex = state.value.indexWhere( (page) => page.results.any((element) => element.id == document.id), ); - if (index != -1) { - final foundPage = state.value[index]; + if (pageIndex != -1) { + final foundPage = state.value[pageIndex]; final replacementPage = foundPage.copyWith( - results: foundPage.results..replaceRange(index, index + 1, [document]), + results: foundPage.results + .map((doc) => doc.id == document.id ? document : doc) + .toList(), ); - emit(state.copyWithPaged( + final newState = state.copyWithPaged( value: state.value .mapIndexed((currIndex, element) => - currIndex == index ? replacementPage : element) + currIndex == pageIndex ? replacementPage : element) .toList(), - )); + ); + emit(newState); } } + + @override + Future close() { + notifier.unsubscribe(this); + return super.close(); + } } diff --git a/lib/features/saved_view/cubit/saved_view_cubit.dart b/lib/features/saved_view/cubit/saved_view_cubit.dart index 2372eb2..6ff9837 100644 --- a/lib/features/saved_view/cubit/saved_view_cubit.dart +++ b/lib/features/saved_view/cubit/saved_view_cubit.dart @@ -34,7 +34,9 @@ class SavedViewCubit extends Cubit { Future initialize() async { final views = await _repository.findAll(); final values = {for (var element in views) element.id!: element}; - emit(SavedViewState(value: values, hasLoaded: true)); + if (!isClosed) { + emit(SavedViewState(value: values, hasLoaded: true)); + } } Future reload() => initialize(); diff --git a/lib/features/saved_view/cubit/saved_view_details_cubit.dart b/lib/features/saved_view/cubit/saved_view_details_cubit.dart new file mode 100644 index 0000000..b9cec31 --- /dev/null +++ b/lib/features/saved_view/cubit/saved_view_details_cubit.dart @@ -0,0 +1,30 @@ +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/features/paged_document_view/model/paged_documents_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; + +part 'saved_view_details_state.dart'; + +class SavedViewDetailsCubit extends Cubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; + + @override + final DocumentChangedNotifier notifier; + + final SavedView savedView; + SavedViewDetailsCubit( + this.api, + this.notifier, { + required this.savedView, + }) : super(const SavedViewDetailsState()) { + notifier.subscribe( + this, + onDeleted: remove, + onUpdated: replace, + ); + updateFilter(filter: savedView.toDocumentFilter()); + } +} diff --git a/lib/features/saved_view/cubit/saved_view_details_state.dart b/lib/features/saved_view/cubit/saved_view_details_state.dart new file mode 100644 index 0000000..653d9e4 --- /dev/null +++ b/lib/features/saved_view/cubit/saved_view_details_state.dart @@ -0,0 +1,47 @@ +part of 'saved_view_details_cubit.dart'; + +class SavedViewDetailsState extends PagedDocumentsState { + const SavedViewDetailsState({ + super.filter, + super.hasLoaded, + super.isLoading, + super.value, + }); + + @override + List get props => [ + filter, + hasLoaded, + isLoading, + value, + ]; + + @override + SavedViewDetailsState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + value: value, + filter: filter, + ); + } + + SavedViewDetailsState copyWith({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return SavedViewDetailsState( + hasLoaded: hasLoaded ?? this.hasLoaded, + isLoading: isLoading ?? this.isLoading, + value: value ?? this.value, + filter: filter ?? this.filter, + ); + } +} 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 761668e..1e1d8ce 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.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/labels/bloc/providers/labels_bloc_provider.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; @@ -17,21 +20,13 @@ class _AddSavedViewPageState extends State { static const fkShowOnDashboard = 'show_on_dashboard'; static const fkShowInSidebar = 'show_in_sidebar'; - final GlobalKey _formKey = GlobalKey(); + final _savedViewFormKey = GlobalKey(); + final _filterFormKey = GlobalKey(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(S.of(context).savedViewCreateNewLabel), - actions: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Tooltip( - child: const Icon(Icons.info_outline), - message: S.of(context).savedViewCreateTooltipText, - ), - ), - ], ), floatingActionButton: FloatingActionButton.extended( icon: const Icon(Icons.add), @@ -40,44 +35,102 @@ class _AddSavedViewPageState extends State { ), body: Padding( padding: const EdgeInsets.all(8.0), - child: FormBuilder( - key: _formKey, - child: ListView( - children: [ - FormBuilderTextField( - name: fkName, - validator: FormBuilderValidators.required(), - decoration: InputDecoration( - label: Text(S.of(context).savedViewNameLabel), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FormBuilder( + key: _savedViewFormKey, + child: Column( + children: [ + FormBuilderTextField( + name: _AddSavedViewPageState.fkName, + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + label: Text(S.of(context).savedViewNameLabel), + ), + ), + FormBuilderCheckbox( + name: _AddSavedViewPageState.fkShowOnDashboard, + initialValue: false, + title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + FormBuilderCheckbox( + name: _AddSavedViewPageState.fkShowInSidebar, + initialValue: false, + title: Text(S.of(context).savedViewShowInSidebarLabel), + ), + ], ), - FormBuilderCheckbox( - name: fkShowOnDashboard, - initialValue: false, - title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + 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, ), - FormBuilderCheckbox( - name: fkShowInSidebar, - initialValue: false, - title: Text(S.of(context).savedViewShowInSidebarLabel), - ), - ], - ), + ), + ], ), ), ); } + Padding _buildOld(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + FormBuilder( + key: _savedViewFormKey, + child: Expanded( + child: ListView( + children: [ + FormBuilderTextField( + name: fkName, + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + label: Text(S.of(context).savedViewNameLabel), + ), + ), + FormBuilderCheckbox( + name: fkShowOnDashboard, + initialValue: false, + title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + FormBuilderCheckbox( + name: fkShowInSidebar, + initialValue: false, + title: Text(S.of(context).savedViewShowInSidebarLabel), + ), + ], + ), + ), + ), + ], + ), + ); + } + void _onCreate(BuildContext context) { - if (_formKey.currentState?.saveAndValidate() ?? false) { + if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { Navigator.pop( context, SavedView.fromDocumentFilter( - widget.currentFilter, - name: _formKey.currentState?.value[fkName] as String, + DocumentFilterForm.assembleFilter( + _filterFormKey, + widget.currentFilter, + ), + name: _savedViewFormKey.currentState?.value[fkName] as String, showOnDashboard: - _formKey.currentState?.value[fkShowOnDashboard] as bool, - showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool, + _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool, + showInSidebar: + _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool, ), ); } diff --git a/lib/features/saved_view/view/saved_view_list.dart b/lib/features/saved_view/view/saved_view_list.dart new file mode 100644 index 0000000..e5af476 --- /dev/null +++ b/lib/features/saved_view/view/saved_view_list.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; +import 'package:paperless_mobile/features/saved_view/view/saved_view_page.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +class SavedViewList extends StatelessWidget { + const SavedViewList({super.key}); + + @override + Widget build(BuildContext context) { + final savedViewCubit = context.read(); + return BlocBuilder( + builder: (context, state) { + if (state.value.isEmpty) { + return SliverToBoxAdapter( + child: HintCard( + hintText: S.of(context).savedViewsEmptyStateText, + ), + ); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final view = state.value.values.elementAt(index); + return ListTile( + title: Text(view.name), + subtitle: Text( + S + .of(context) + .savedViewsFiltersSetCount(view.filterRules.length), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SavedViewDetailsCubit( + context.read(), + context.read(), + savedView: view, + ), + ), + ], + child: SavedViewPage( + onDelete: savedViewCubit.remove, + ), + ), + ), + ); + }, + ); + }, + childCount: state.value.length, + ), + ); + }, + ); + } +} diff --git a/lib/features/saved_view/view/saved_view_page.dart b/lib/features/saved_view/view/saved_view_page.dart new file mode 100644 index 0000000..969f91e --- /dev/null +++ b/lib/features/saved_view/view/saved_view_page.dart @@ -0,0 +1,130 @@ +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/repository/provider/label_repositories_provider.dart'; +import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/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/view_actions.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/features/settings/model/view_type.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; + +class SavedViewPage extends StatefulWidget { + final Future Function(SavedView savedView) onDelete; + const SavedViewPage({ + super.key, + required this.onDelete, + }); + + @override + State createState() => _SavedViewPageState(); +} + +class _SavedViewPageState extends State { + final _scrollController = ScrollController(); + ViewType _viewType = ViewType.list; + SavedView get _savedView => context.read().savedView; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_listenForLoadNewData); + } + + void _listenForLoadNewData() async { + final currState = context.read().state; + if (_scrollController.offset >= + _scrollController.position.maxScrollExtent * 0.7 && + !currState.isLoading && + !currState.isLastPageLoaded) { + try { + await context.read().loadMore(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: BlocBuilder( + builder: (context, state) { + return Text(_savedView.name); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => + ConfirmDeleteSavedViewDialog(view: _savedView), + ) ?? + false; + if (shouldDelete) { + await widget.onDelete(_savedView); + Navigator.pop(context); + } + }, + ), + IconButton( + icon: Icon( + _viewType == ViewType.list ? Icons.grid_view_rounded : Icons.list, + ), + onPressed: () => setState(() => _viewType = _viewType.toggle()), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.hasLoaded && state.documents.isEmpty) { + return DocumentsEmptyState(state: state); + } + return BlocBuilder( + builder: (context, connectivity) { + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + onTap: _onOpenDocumentDetails, + viewType: _viewType, + ), + if (state.hasLoaded && state.isLoading) + const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ) + ], + ); + }, + ); + }, + ), + ); + } + + void _onOpenDocumentDetails(DocumentModel document) { + Navigator.pushNamed( + context, + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: document, + isLabelClickable: false, + ), + ); + } +} diff --git a/lib/features/saved_view/view/saved_view_selection_widget.dart b/lib/features/saved_view/view/saved_view_selection_widget.dart index 0d96af6..43d71b3 100644 --- a/lib/features/saved_view/view/saved_view_selection_widget.dart +++ b/lib/features/saved_view/view/saved_view_selection_widget.dart @@ -1,217 +1,218 @@ -import 'dart:math'; +// import 'dart:math'; -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/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; -import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; -import 'package:shimmer/shimmer.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/bloc/connectivity_cubit.dart'; +// import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +// import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +// import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; +// import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; +// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; +// import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; +// import 'package:paperless_mobile/generated/l10n.dart'; +// import 'package:paperless_mobile/helpers/message_helpers.dart'; +// import 'package:paperless_mobile/constants.dart'; +// import 'package:shimmer/shimmer.dart'; -class SavedViewSelectionWidget extends StatelessWidget { - final DocumentFilter currentFilter; - const SavedViewSelectionWidget({ - Key? key, - required this.height, - required this.enabled, - required this.currentFilter, - }) : super(key: key); +// class SavedViewSelectionWidget extends StatelessWidget { +// final DocumentFilter currentFilter; +// const SavedViewSelectionWidget({ +// Key? key, +// required this.height, +// required this.enabled, +// required this.currentFilter, +// }) : super(key: key); - final double height; - final bool enabled; +// final double height; +// final bool enabled; - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivityState) { - final hasInternetConnection = connectivityState.isConnected; - return SizedBox( - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - BlocBuilder( - builder: (context, state) { - if (!state.hasLoaded) { - return _buildLoadingWidget(context); - } - if (state.value.isEmpty) { - return Text(S.of(context).savedViewsEmptyStateText); - } - return SizedBox( - height: 38, - child: ListView.separated( - itemCount: state.value.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final view = state.value.values.elementAt(index); - return GestureDetector( - onLongPress: hasInternetConnection - ? () => _onDelete(context, view) - : null, - child: BlocBuilder( - builder: (context, docState) { - final view = state.value.values.toList()[index]; - return FilterChip( - label: Text( - view.name, - ), - selected: - view.id == docState.selectedSavedViewId, - onSelected: enabled && hasInternetConnection - ? (isSelected) => - _onSelected(isSelected, context, view) - : null, - ); - }, - ), - ); - }, - separatorBuilder: (context, index) => const SizedBox( - width: 4.0, - ), - ), - ); - }, - ), - BlocBuilder( - builder: (context, state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - S.of(context).savedViewsLabel, - style: Theme.of(context).textTheme.titleSmall, - ), - BlocBuilder( - buildWhen: (previous, current) => - previous.filter != current.filter, - builder: (context, docState) { - return TextButton.icon( - icon: const Icon(Icons.add), - onPressed: (enabled && - state.hasLoaded && - hasInternetConnection) - ? () => - _onCreatePressed(context, docState.filter) - : null, - label: Text(S.of(context).savedViewCreateNewLabel), - ); - }, - ), - ], - ); - }, - ), - ], - ).padded(), - ); - }, - ); - } +// @override +// Widget build(BuildContext context) { +// return BlocBuilder( +// builder: (context, connectivityState) { +// final hasInternetConnection = connectivityState.isConnected; +// return SizedBox( +// height: height, +// child: Column( +// mainAxisAlignment: MainAxisAlignment.start, +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisSize: MainAxisSize.min, +// children: [ +// BlocBuilder( +// builder: (context, state) { +// if (!state.hasLoaded) { +// return _buildLoadingWidget(context); +// } +// if (state.value.isEmpty) { +// return Text(S.of(context).savedViewsEmptyStateText); +// } +// return SizedBox( +// height: 38, +// child: ListView.separated( +// itemCount: state.value.length, +// scrollDirection: Axis.horizontal, +// itemBuilder: (context, index) { +// final view = state.value.values.elementAt(index); +// return GestureDetector( +// onLongPress: hasInternetConnection +// ? () => _onDelete(context, view) +// : null, +// child: BlocBuilder( +// builder: (context, docState) { +// final view = state.value.values.toList()[index]; +// return FilterChip( +// label: Text( +// view.name, +// ), +// selected: +// view.id == docState.selectedSavedViewId, +// onSelected: enabled && hasInternetConnection +// ? (isSelected) => +// _onSelected(isSelected, context, view) +// : null, +// ); +// }, +// ), +// ); +// }, +// separatorBuilder: (context, index) => const SizedBox( +// width: 4.0, +// ), +// ), +// ); +// }, +// ), +// BlocBuilder( +// builder: (context, state) { +// return Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// Text( +// S.of(context).savedViewsLabel, +// style: Theme.of(context).textTheme.titleSmall, +// ), +// BlocBuilder( +// buildWhen: (previous, current) => +// previous.filter != current.filter, +// builder: (context, docState) { +// return TextButton.icon( +// icon: const Icon(Icons.add), +// onPressed: (enabled && +// state.hasLoaded && +// hasInternetConnection) +// ? () => +// _onCreatePressed(context, docState.filter) +// : null, +// label: Text(S.of(context).savedViewCreateNewLabel), +// ); +// }, +// ), +// ], +// ); +// }, +// ), +// ], +// ).padded(), +// ); +// }, +// ); +// } - Widget _buildLoadingWidget(BuildContext context) { - return SizedBox( - height: 38, - width: MediaQuery.of(context).size.width, - child: Shimmer.fromColors( - baseColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[300]! - : Colors.grey[900]!, - highlightColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[100]! - : Colors.grey[600]!, - child: ListView( - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - children: [ - FilterChip( - label: const SizedBox(width: 32), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 64), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 100), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 32), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 48), - onSelected: (_) {}, - ), - ], - ), - ), - ); - } +// Widget _buildLoadingWidget(BuildContext context) { +// return SizedBox( +// height: 38, +// width: MediaQuery.of(context).size.width, +// child: Shimmer.fromColors( +// baseColor: Theme.of(context).brightness == Brightness.light +// ? Colors.grey[300]! +// : Colors.grey[900]!, +// highlightColor: Theme.of(context).brightness == Brightness.light +// ? Colors.grey[100]! +// : Colors.grey[600]!, +// child: ListView( +// scrollDirection: Axis.horizontal, +// physics: const NeverScrollableScrollPhysics(), +// children: [ +// FilterChip( +// label: const SizedBox(width: 32), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 64), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 100), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 32), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 48), +// onSelected: (_) {}, +// ), +// ], +// ), +// ), +// ); +// } - void _onCreatePressed(BuildContext context, DocumentFilter filter) async { - final newView = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => AddSavedViewPage( - currentFilter: filter, - ), - ), - ); - if (newView != null) { - try { - await context.read().add(newView); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } +// void _onCreatePressed(BuildContext context, DocumentFilter filter) async { +// final newView = await Navigator.of(context).push( +// MaterialPageRoute( +// builder: (context) => AddSavedViewPage( +// currentFilter: filter, +// ), +// ), +// ); +// if (newView != null) { +// try { +// await context.read().add(newView); +// } on PaperlessServerException catch (error, stackTrace) { +// showErrorMessage(context, error, stackTrace); +// } +// } +// } - void _onSelected( - bool selectionIntent, - BuildContext context, - SavedView view, - ) async { - if (selectionIntent) { - context.read().selectView(view.id!); - } else { - context.read().unselectView(); - context.read().resetFilter(); - } - } +// void _onSelected( +// bool selectionIntent, +// BuildContext context, +// SavedView view, +// ) async { +// if (selectionIntent) { +// context.read().selectView(view.id!); +// } else { +// context.read().unselectView(); +// context.read().resetFilter(); +// } +// } - void _onDelete(BuildContext context, SavedView view) async { - { - final delete = await showDialog( - context: context, - builder: (context) => ConfirmDeleteSavedViewDialog(view: view), - ) ?? - false; - if (delete) { - try { - context.read().remove(view); - if (context.read().state.selectedSavedViewId == - view.id) { - await context.read().resetFilter(); - } - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } - } -} +// void _onDelete(BuildContext context, SavedView view) async { +// { +// final delete = await showDialog( +// context: context, +// builder: (context) => ConfirmDeleteSavedViewDialog(view: view), +// ) ?? +// false; +// if (delete) { +// try { +// context.read().remove(view); +// if (context.read().state.selectedSavedViewId == +// view.id) { +// await context.read().resetFilter(); +// } +// } on PaperlessServerException catch (error, stackTrace) { +// showErrorMessage(context, error, stackTrace); +// } +// } +// } +// } +// } diff --git a/lib/features/scan/view/scanner_page.dart b/lib/features/scan/view/scanner_page.dart index 7ae6bd8..3639316 100644 --- a/lib/features/scan/view/scanner_page.dart +++ b/lib/features/scan/view/scanner_page.dart @@ -12,22 +12,21 @@ import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/global/constants.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/offline_banner.dart'; +import 'package:paperless_mobile/features/app_drawer/view/app_drawer.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/documents/view/pages/document_view.dart'; -import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart'; import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; -import 'package:paperless_mobile/features/scan/view/widgets/grid_image_item_widget.dart'; +import 'package:paperless_mobile/features/scan/view/widgets/scanned_image_item.dart'; +import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/util.dart'; +import 'package:paperless_mobile/helpers/file_helpers.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; @@ -44,6 +43,15 @@ class _ScannerPageState extends State with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { + final safeAreaPadding = MediaQuery.of(context).padding; + final availableHeight = MediaQuery.of(context).size.height - + 2 * kToolbarHeight - + kTextTabBarHeight - + kBottomNavigationBarHeight - + safeAreaPadding.top - + safeAreaPadding.bottom; + + print(availableHeight); return BlocBuilder( builder: (context, connectedState) { return Scaffold( @@ -52,16 +60,122 @@ class _ScannerPageState extends State onPressed: () => _openDocumentScanner(context), child: const Icon(Icons.add_a_photo_outlined), ), - appBar: _buildAppBar(context, connectedState.isConnected), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: _buildBody(connectedState.isConnected), + //appBar: _buildAppBar(context, connectedState.isConnected), + // body: Padding( + // padding: const EdgeInsets.all(8.0), + // child: _buildBody(connectedState.isConnected), + // ), + body: BlocBuilder>( + builder: (context, state) { + return CustomScrollView( + physics: + state.isEmpty ? const NeverScrollableScrollPhysics() : null, + slivers: [ + SearchAppBar( + hintText: S.of(context).documentSearchSearchDocuments, + onOpenSearch: showDocumentSearchPage, + bottom: PreferredSize( + child: _buildActions(connectedState.isConnected), + preferredSize: const Size.fromHeight(kTextTabBarHeight), + ), + ), + if (state.isEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: availableHeight, + child: Center( + child: _buildEmptyState(connectedState.isConnected), + ), + ), + ) + else + _buildImageGrid(state) + ], + ); + + NestedScrollView( + floatHeaderSlivers: false, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SearchAppBar( + hintText: S.of(context).documentSearchSearchDocuments, + onOpenSearch: showDocumentSearchPage, + bottom: PreferredSize( + child: _buildActions(connectedState.isConnected), + preferredSize: const Size.fromHeight(kTextTabBarHeight), + ), + ), + ], + body: CustomScrollView( + slivers: [ + if (state.isEmpty) + SliverFillViewport( + delegate: SliverChildListDelegate.fixed( + [_buildEmptyState(connectedState.isConnected)]), + ) + else + _buildImageGrid(state) + ], + ), + ); + }, ), ); }, ); } + Widget _buildActions(bool isConnected) { + return SizedBox( + height: kTextTabBarHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + BlocBuilder>( + builder: (context, state) { + return TextButton.icon( + label: Text(S.of(context).scannerPagePreviewLabel), + onPressed: state.isNotEmpty + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DocumentView( + documentBytes: _assembleFileBytes( + state, + forcePdf: true, + ).then((file) => file.bytes), + ), + ), + ) + : null, + icon: const Icon(Icons.visibility_outlined), + ); + }, + ), + BlocBuilder>( + builder: (context, state) { + return TextButton.icon( + label: Text(S.of(context).scannerPageClearAllLabel), + onPressed: state.isEmpty ? null : () => _reset(context), + icon: const Icon(Icons.delete_sweep_outlined), + ); + }, + ), + BlocBuilder>( + builder: (context, state) { + return TextButton.icon( + label: Text(S.of(context).scannerPageUploadLabel), + onPressed: state.isEmpty || !isConnected + ? null + : () => _onPrepareDocumentUpload(context), + icon: const Icon(Icons.upload_outlined), + ); + }, + ), + ], + ), + ); + } + AppBar _buildAppBar(BuildContext context, bool isConnected) { return AppBar( title: Text(S.of(context).documentScannerPageTitle), @@ -147,15 +261,12 @@ class _ScannerPageState extends State builder: (_) => LabelRepositoriesProvider( child: BlocProvider( create: (context) => DocumentUploadCubit( - localVault: context.read(), documentApi: context.read(), - correspondentRepository: context.read< - LabelRepository>(), - documentTypeRepository: context.read< - LabelRepository>(), - tagRepository: - context.read>(), + correspondentRepository: + context.read>(), + documentTypeRepository: + context.read>(), + tagRepository: context.read>(), ), child: DocumentUploadPreparationPage( fileBytes: file.bytes, @@ -172,7 +283,7 @@ class _ScannerPageState extends State } } - Widget _buildBody(bool isConnected) { + Widget _buildEmptyState(bool isConnected) { return BlocBuilder>( builder: (context, scans) { if (scans.isNotEmpty) { @@ -209,7 +320,7 @@ class _ScannerPageState extends State } Widget _buildImageGrid(List scans) { - return GridView.builder( + return SliverGrid.builder( itemCount: scans.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, @@ -218,7 +329,7 @@ class _ScannerPageState extends State mainAxisSpacing: 10, ), itemBuilder: (context, index) { - return GridImageItemWidget( + return ScannedImageItem( file: scans[index], onDelete: () async { try { @@ -265,16 +376,12 @@ class _ScannerPageState extends State builder: (_) => LabelRepositoriesProvider( child: BlocProvider( create: (context) => DocumentUploadCubit( - localVault: context.read(), documentApi: context.read(), - correspondentRepository: context.read< - LabelRepository>(), - documentTypeRepository: context.read< - LabelRepository>(), - tagRepository: - context.read>(), + correspondentRepository: + context.read>(), + documentTypeRepository: + context.read>(), + tagRepository: context.read>(), ), child: DocumentUploadPreparationPage( fileBytes: file.readAsBytesSync(), diff --git a/lib/features/scan/view/widgets/grid_image_item_widget.dart b/lib/features/scan/view/widgets/scanned_image_item.dart similarity index 94% rename from lib/features/scan/view/widgets/grid_image_item_widget.dart rename to lib/features/scan/view/widgets/scanned_image_item.dart index 6997bbb..6f10a26 100644 --- a/lib/features/scan/view/widgets/grid_image_item_widget.dart +++ b/lib/features/scan/view/widgets/scanned_image_item.dart @@ -7,7 +7,7 @@ import 'package:photo_view/photo_view.dart'; typedef DeleteCallback = void Function(); typedef OnImageOperation = void Function(File); -class GridImageItemWidget extends StatefulWidget { +class ScannedImageItem extends StatefulWidget { final File file; final DeleteCallback onDelete; //final OnImageOperation onImageOperation; @@ -15,7 +15,7 @@ class GridImageItemWidget extends StatefulWidget { final int index; final int totalNumberOfFiles; - const GridImageItemWidget({ + const ScannedImageItem({ Key? key, required this.file, required this.onDelete, @@ -25,10 +25,10 @@ class GridImageItemWidget extends StatefulWidget { }) : super(key: key); @override - State createState() => _GridImageItemWidgetState(); + State createState() => _ScannedImageItemState(); } -class _GridImageItemWidgetState extends State { +class _ScannedImageItemState extends State { @override Widget build(BuildContext context) { return GestureDetector( diff --git a/lib/features/scan/view/widgets/scanner.dart b/lib/features/scan/view/widgets/scanner.dart deleted file mode 100644 index 6b6e76a..0000000 --- a/lib/features/scan/view/widgets/scanner.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/util.dart'; -import 'package:permission_handler/permission_handler.dart'; - -typedef OnImageScannedCallback = void Function(File); - -class ScannerWidget extends StatefulWidget { - final OnImageScannedCallback onImageScannedCallback; - const ScannerWidget({ - Key? key, - required this.onImageScannedCallback, - }) : super(key: key); - - @override - _ScannerWidgetState createState() => _ScannerWidgetState(); -} - -class _ScannerWidgetState extends State { - List documents = List.empty(growable: true); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text("Scan document")), - body: FutureBuilder( - future: askForPermission(Permission.camera), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.data!) { - return Container(); - } - return const Center( - child: Text("No camera permissions, please enable in settings!"), - ); - }), - ); - } -} diff --git a/lib/features/scan/view/widgets/upload_dialog.dart b/lib/features/scan/view/widgets/upload_dialog.dart deleted file mode 100644 index 619b0b2..0000000 --- a/lib/features/scan/view/widgets/upload_dialog.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -class UploadDialog extends StatefulWidget { - const UploadDialog({ - Key? key, - }) : super(key: key); - - @override - State createState() => _UploadDialogState(); -} - -class _UploadDialogState extends State { - late TextEditingController _controller; - final _formKey = GlobalKey(); - - @override - void initState() { - final DateFormat format = DateFormat("yyyy_MM_dd_hh_mm_ss"); - final today = format.format(DateTime.now()); - _controller = TextEditingController.fromValue( - TextEditingValue(text: "Scan_$today.pdf")); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text("Upload to paperless-ng"), - content: Form( - key: _formKey, - child: TextFormField( - controller: _controller, - validator: (text) { - if (text == null || text.isEmpty) { - return "Filename must be specified!"; - } - return null; - }, - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("Cancel"), - ), - TextButton( - onPressed: () { - if (!_formKey.currentState!.validate()) { - return; - } - var txt = _controller.text; - if (!txt.endsWith(".pdf")) { - txt += ".pdf"; - } - Navigator.of(context).pop(txt); - }, - child: const Text("Upload"), - ), - ], - ); - } -} diff --git a/lib/features/search_app_bar/view/search_app_bar.dart b/lib/features/search_app_bar/view/search_app_bar.dart new file mode 100644 index 0000000..ee70481 --- /dev/null +++ b/lib/features/search_app_bar/view/search_app_bar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart'; + +typedef OpenSearchCallback = void Function(BuildContext context); + +class SearchAppBar extends StatefulWidget with PreferredSizeWidget { + final PreferredSizeWidget? bottom; + final OpenSearchCallback onOpenSearch; + final Color? backgroundColor; + final String hintText; + const SearchAppBar({ + super.key, + required this.onOpenSearch, + this.bottom, + this.backgroundColor, + required this.hintText, + }); + + @override + State createState() => _SearchAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _SearchAppBarState extends State { + @override + Widget build(BuildContext context) { + return SliverAppBar( + automaticallyImplyLeading: false, + floating: true, + pinned: true, + snap: true, + backgroundColor: widget.backgroundColor, + title: SearchBar( + height: kToolbarHeight - 8, + supportingText: widget.hintText, + onTap: () => widget.onOpenSearch(context), + leadingIcon: IconButton( + icon: const Icon(Icons.menu), + onPressed: Scaffold.of(context).openDrawer, + ), + trailingIcon: IconButton( + icon: const CircleAvatar( + child: Text("A"), + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => AccountSettingsDialog(), + ); + }, + ), + ).paddedOnly(top: 4, bottom: 4), + bottom: widget.bottom, + ); + } +} diff --git a/lib/features/settings/bloc/application_settings_cubit.dart b/lib/features/settings/bloc/application_settings_cubit.dart index 8d738ff..7d2c314 100644 --- a/lib/features/settings/bloc/application_settings_cubit.dart +++ b/lib/features/settings/bloc/application_settings_cubit.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; class ApplicationSettingsCubit extends HydratedCubit { @@ -27,17 +28,23 @@ class ApplicationSettingsCubit extends HydratedCubit { } } - Future setThemeMode(ThemeMode? selectedMode) async { + void setThemeMode(ThemeMode? selectedMode) { final updatedSettings = state.copyWith(preferredThemeMode: selectedMode); _updateSettings(updatedSettings); } - Future setViewType(ViewType viewType) async { + void setViewType(ViewType viewType) { final updatedSettings = state.copyWith(preferredViewType: viewType); _updateSettings(updatedSettings); } - Future _updateSettings(ApplicationSettingsState settings) async { + void setColorSchemeOption(ColorSchemeOption schemeOption) { + final updatedSettings = + state.copyWith(preferredColorSchemeOption: schemeOption); + _updateSettings(updatedSettings); + } + + void _updateSettings(ApplicationSettingsState settings) async { emit(settings); } diff --git a/lib/features/settings/model/application_settings_state.dart b/lib/features/settings/bloc/application_settings_state.dart similarity index 61% rename from lib/features/settings/model/application_settings_state.dart rename to lib/features/settings/bloc/application_settings_state.dart index 05c284d..108a13a 100644 --- a/lib/features/settings/model/application_settings_state.dart +++ b/lib/features/settings/bloc/application_settings_state.dart @@ -2,8 +2,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_mobile/core/type/types.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; part 'application_settings_state.g.dart'; @@ -13,22 +14,21 @@ part 'application_settings_state.g.dart'; @JsonSerializable() class ApplicationSettingsState { static final defaultSettings = ApplicationSettingsState( - isLocalAuthenticationEnabled: false, - preferredLocaleSubtag: Platform.localeName.split('_').first, - preferredThemeMode: ThemeMode.system, - preferredViewType: ViewType.list, + preferredLocaleSubtag: _defaultPreferredLocaleSubtag, ); final bool isLocalAuthenticationEnabled; final String preferredLocaleSubtag; final ThemeMode preferredThemeMode; final ViewType preferredViewType; + final ColorSchemeOption preferredColorSchemeOption; ApplicationSettingsState({ required this.preferredLocaleSubtag, - required this.preferredThemeMode, - required this.isLocalAuthenticationEnabled, - required this.preferredViewType, + this.preferredThemeMode = ThemeMode.system, + this.isLocalAuthenticationEnabled = false, + this.preferredViewType = ViewType.list, + this.preferredColorSchemeOption = ColorSchemeOption.classic, }); Map toJson() => _$ApplicationSettingsStateToJson(this); @@ -40,6 +40,7 @@ class ApplicationSettingsState { String? preferredLocaleSubtag, ThemeMode? preferredThemeMode, ViewType? preferredViewType, + ColorSchemeOption? preferredColorSchemeOption, }) { return ApplicationSettingsState( isLocalAuthenticationEnabled: @@ -48,6 +49,17 @@ class ApplicationSettingsState { preferredLocaleSubtag ?? this.preferredLocaleSubtag, preferredThemeMode: preferredThemeMode ?? this.preferredThemeMode, preferredViewType: preferredViewType ?? this.preferredViewType, + preferredColorSchemeOption: + preferredColorSchemeOption ?? this.preferredColorSchemeOption, ); } + + static String get _defaultPreferredLocaleSubtag { + String preferredLocale = Platform.localeName.split("_").first; + if (!S.delegate.supportedLocales + .any((locale) => locale.languageCode == preferredLocale)) { + preferredLocale = 'en'; + } + return preferredLocale; + } } diff --git a/lib/features/settings/model/application_settings_state.g.dart b/lib/features/settings/bloc/application_settings_state.g.dart similarity index 63% rename from lib/features/settings/model/application_settings_state.g.dart rename to lib/features/settings/bloc/application_settings_state.g.dart index 6166a38..c06b98b 100644 --- a/lib/features/settings/model/application_settings_state.g.dart +++ b/lib/features/settings/bloc/application_settings_state.g.dart @@ -11,11 +11,16 @@ ApplicationSettingsState _$ApplicationSettingsStateFromJson( ApplicationSettingsState( preferredLocaleSubtag: json['preferredLocaleSubtag'] as String, preferredThemeMode: - $enumDecode(_$ThemeModeEnumMap, json['preferredThemeMode']), + $enumDecodeNullable(_$ThemeModeEnumMap, json['preferredThemeMode']) ?? + ThemeMode.system, isLocalAuthenticationEnabled: - json['isLocalAuthenticationEnabled'] as bool, + json['isLocalAuthenticationEnabled'] as bool? ?? false, preferredViewType: - $enumDecode(_$ViewTypeEnumMap, json['preferredViewType']), + $enumDecodeNullable(_$ViewTypeEnumMap, json['preferredViewType']) ?? + ViewType.list, + preferredColorSchemeOption: $enumDecodeNullable( + _$ColorSchemeOptionEnumMap, json['preferredColorSchemeOption']) ?? + ColorSchemeOption.classic, ); Map _$ApplicationSettingsStateToJson( @@ -25,6 +30,8 @@ Map _$ApplicationSettingsStateToJson( 'preferredLocaleSubtag': instance.preferredLocaleSubtag, 'preferredThemeMode': _$ThemeModeEnumMap[instance.preferredThemeMode]!, 'preferredViewType': _$ViewTypeEnumMap[instance.preferredViewType]!, + 'preferredColorSchemeOption': + _$ColorSchemeOptionEnumMap[instance.preferredColorSchemeOption]!, }; const _$ThemeModeEnumMap = { @@ -37,3 +44,8 @@ const _$ViewTypeEnumMap = { ViewType.grid: 'grid', ViewType.list: 'list', }; + +const _$ColorSchemeOptionEnumMap = { + ColorSchemeOption.classic: 'classic', + ColorSchemeOption.dynamic: 'dynamic', +}; diff --git a/lib/features/settings/model/color_scheme_option.dart b/lib/features/settings/model/color_scheme_option.dart new file mode 100644 index 0000000..6bd92e3 --- /dev/null +++ b/lib/features/settings/model/color_scheme_option.dart @@ -0,0 +1,4 @@ +enum ColorSchemeOption { + classic, + dynamic; +} diff --git a/lib/features/settings/view/dialogs/account_settings_dialog.dart b/lib/features/settings/view/dialogs/account_settings_dialog.dart new file mode 100644 index 0000000..9d2aba3 --- /dev/null +++ b/lib/features/settings/view/dialogs/account_settings_dialog.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_state.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/widgets/hint_card.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +class AccountSettingsDialog extends StatelessWidget { + const AccountSettingsDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + scrollable: true, + contentPadding: EdgeInsets.zero, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).accountSettingsTitle), + const CloseButton(), + ], + ), + content: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + ExpansionTile( + leading: CircleAvatar( + child: Text(state.information?.username + ?.toUpperCase() + .substring(0, 1) ?? + ''), + ), + title: Text(state.information?.username ?? ''), + subtitle: Text(state.information?.host ?? ''), + children: const [ + HintCard( + hintText: "WIP: Coming soon with multi user support!", + ), + ], + ), + Divider(), + ListTile( + dense: true, + leading: const Icon(Icons.person_add_rounded), + title: Text(S.of(context).accountSettingsAddAnotherAccount), + onTap: () {}, + ), + Divider(), + FilledButton( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.error, + ), + ), + child: Text( + S.of(context).appDrawerLogoutLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + ), + ), + onPressed: () async { + await _onLogout(context); + Navigator.of(context).maybePop(); + }, + ).padded(16), + ], + ); + }, + ), + ); + } + + Future _onLogout(BuildContext context) async { + try { + await context.read().logout(); + await context.read().clear(); + await context.read>().clear(); + await context.read>().clear(); + await context.read>().clear(); + await context.read>().clear(); + await context.read().clear(); + await HydratedBloc.storage.clear(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } +} diff --git a/lib/features/settings/view/pages/application_settings_page.dart b/lib/features/settings/view/pages/application_settings_page.dart index b1b0720..6c8acb3 100644 --- a/lib/features/settings/view/pages/application_settings_page.dart +++ b/lib/features/settings/view/pages/application_settings_page.dart @@ -1,7 +1,12 @@ +import 'dart:developer'; +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/color_scheme_option_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/language_selection_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/theme_mode_setting.dart'; import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/constants.dart'; class ApplicationSettingsPage extends StatelessWidget { const ApplicationSettingsPage({super.key}); @@ -16,6 +21,7 @@ class ApplicationSettingsPage extends StatelessWidget { children: const [ LanguageSelectionSetting(), ThemeModeSetting(), + ColorSchemeOptionSetting(), ], ), ); diff --git a/lib/features/settings/view/pages/storage_settings_page.dart b/lib/features/settings/view/pages/storage_settings_page.dart index 0aefad0..1dba0e8 100644 --- a/lib/features/settings/view/pages/storage_settings_page.dart +++ b/lib/features/settings/view/pages/storage_settings_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:paperless_mobile/features/settings/view/widgets/clear_storage_setting.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/clear_storage_settings.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class StorageSettingsPage extends StatelessWidget { @@ -13,7 +13,7 @@ class StorageSettingsPage extends StatelessWidget { ), body: ListView( children: const [ - ClearStorageSetting(), + ClearCacheSetting(), ], ), ); diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 89db6ad..2fffad6 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,10 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_state.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/state/impl/correspondent_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; +import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart'; import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart'; import 'package:paperless_mobile/features/settings/view/pages/storage_settings_page.dart'; import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -15,21 +27,49 @@ class SettingsPage extends StatelessWidget { appBar: AppBar( title: Text(S.of(context).appDrawerSettingsLabel), ), + bottomNavigationBar: BlocBuilder( + builder: (context, state) { + final info = state.information!; + + return ListTile( + title: Text( + S.of(context).appDrawerHeaderLoggedInAsText + + " " + + (info.username ?? 'unknown') + + "@${info.host}", + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + subtitle: Text( + S.of(context).serverInformationPaperlessVersionText + + ' ' + + info.version.toString() + + ' (API v${info.apiVersion})', + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ); + }, + ), body: ListView( children: [ ListTile( + // leading: const Icon(Icons.style_outlined), title: Text(S.of(context).settingsPageApplicationSettingsLabel), subtitle: Text( S.of(context).settingsPageApplicationSettingsDescriptionText), onTap: () => _goto(const ApplicationSettingsPage(), context), ), ListTile( + // leading: const Icon(Icons.security_outlined), title: Text(S.of(context).settingsPageSecuritySettingsLabel), subtitle: Text(S.of(context).settingsPageSecuritySettingsDescriptionText), onTap: () => _goto(const SecuritySettingsPage(), context), ), ListTile( + // leading: const Icon(Icons.storage_outlined), title: Text(S.of(context).settingsPageStorageSettingsLabel), subtitle: Text(S.of(context).settingsPageStorageSettingsDescriptionText), diff --git a/lib/features/settings/view/widgets/biometric_authentication_setting.dart b/lib/features/settings/view/widgets/biometric_authentication_setting.dart index 3a49d6e..b182f42 100644 --- a/lib/features/settings/view/widgets/biometric_authentication_setting.dart +++ b/lib/features/settings/view/widgets/biometric_authentication_setting.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:provider/provider.dart'; diff --git a/lib/features/settings/view/widgets/clear_storage_setting.dart b/lib/features/settings/view/widgets/clear_storage_setting.dart deleted file mode 100644 index a956cfe..0000000 --- a/lib/features/settings/view/widgets/clear_storage_setting.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:provider/provider.dart'; - -class ClearStorageSetting extends StatelessWidget { - const ClearStorageSetting({super.key}); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text("Clear data"), - subtitle: - Text("Remove downloaded files, scans and clear the cache's content"), - onTap: () { - context.read().emptyCache(); - FileService.clearUserData(); - }, - ); - } -} diff --git a/lib/features/settings/view/widgets/clear_storage_settings.dart b/lib/features/settings/view/widgets/clear_storage_settings.dart new file mode 100644 index 0000000..78709bf --- /dev/null +++ b/lib/features/settings/view/widgets/clear_storage_settings.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/helpers/format_helpers.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:provider/provider.dart'; + +class ClearCacheSetting extends StatelessWidget { + const ClearCacheSetting({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text("Clear downloaded files"), //TODO: INTL + subtitle: + Text("Deletes all files downloaded from this app."), //TODO: INTL + onTap: () async { + final dir = await FileService.downloadsDirectory; + final deletedSize = _dirSize(dir); + await dir.delete(recursive: true); + // await context.read().emptyCache(); + showSnackBar( + context, + "Downloads successfully cleared, removed $deletedSize.", + ); + }, + ); + } +} + +class ClearDownloadsSetting extends StatelessWidget { + const ClearDownloadsSetting({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text("Clear downloads"), //TODO: INTL + subtitle: Text( + "Remove downloaded files, scans and clear the cache's content"), //TODO: INTL + onTap: () { + FileService.documentsDirectory; + FileService.downloadsDirectory; + context.read().emptyCache(); + FileService.clearUserData(); + //TODO: Show notification about clearing (include size?) + }, + ); + } +} + +String _dirSize(Directory dir) { + int totalSize = 0; + try { + if (dir.existsSync()) { + dir + .listSync(recursive: true, followLinks: false) + .forEach((FileSystemEntity entity) { + if (entity is File) { + totalSize += entity.lengthSync(); + } + }); + } + } catch (e) { + print(e.toString()); + } + + return formatBytes(totalSize, 2); +} diff --git a/lib/features/settings/view/widgets/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart new file mode 100644 index 0000000..15a6566 --- /dev/null +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; +import 'package:paperless_mobile/features/login/services/authentication_service.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/color_scheme_option.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/constants.dart'; +import 'package:provider/provider.dart'; + +class ColorSchemeOptionSetting extends StatelessWidget { + const ColorSchemeOptionSetting({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, settings) { + return ListTile( + title: Text(S.of(context).settingsPageColorSchemeSettingLabel), + subtitle: Text( + translateColorSchemeOption( + context, + settings.preferredColorSchemeOption, + ), + ), + onTap: () => showDialog( + context: context, + builder: (_) => RadioSettingsDialog( + titleText: S.of(context).settingsPageColorSchemeSettingLabel, + descriptionText: + S.of(context).settingsPageColorSchemeSettingDialogDescription, + options: [ + RadioOption( + value: ColorSchemeOption.classic, + label: translateColorSchemeOption( + context, ColorSchemeOption.classic), + ), + RadioOption( + value: ColorSchemeOption.dynamic, + label: translateColorSchemeOption( + context, + ColorSchemeOption.dynamic, + ), + ), + ], + footer: _isBelowAndroid12() + ? HintCard( + hintText: S + .of(context) + .settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning, + hintIcon: Icons.warning_amber, + ) + : null, + initialValue: context + .read() + .state + .preferredColorSchemeOption, + ), + ).then( + (value) { + if (value != null) { + context + .read() + .setColorSchemeOption(value); + } + }, + ), + ); + }, + ); + } + + bool _isBelowAndroid12() { + if (Platform.isAndroid) { + final int version = + int.tryParse(androidInfo!.version.release ?? '0') ?? 0; + return version < 12; + } + return false; + } +} diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index b2b0a09..15a5ed3 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -20,6 +21,7 @@ class _LanguageSelectionSettingState extends State { 'cs': 'Česky', 'tr': 'Türkçe', }; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -27,10 +29,13 @@ class _LanguageSelectionSettingState extends State { return ListTile( title: Text(S.of(context).settingsPageLanguageSettingLabel), subtitle: Text(_languageOptions[settings.preferredLocaleSubtag]!), - onTap: () => showDialog( + onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( - title: Text(S.of(context).settingsPageLanguageSettingLabel), + footer: const Text( + "* Work in progress, not fully translated yet. Some words may be displayed in English!", + ), + titleText: S.of(context).settingsPageLanguageSettingLabel, options: [ RadioOption( value: 'en', @@ -42,11 +47,11 @@ class _LanguageSelectionSettingState extends State { ), RadioOption( value: 'cs', - label: _languageOptions['cs']!, + label: _languageOptions['cs']! + "*", ), RadioOption( value: 'tr', - label: _languageOptions['tr']!, + label: _languageOptions['tr']! + "*", ) ], initialValue: context @@ -54,8 +59,11 @@ class _LanguageSelectionSettingState extends State { .state .preferredLocaleSubtag, ), - ).then((value) => - context.read().setLocale(value)), + ).then((value) { + if (value != null) { + context.read().setLocale(value); + } + }), ); }, ); diff --git a/lib/features/settings/view/widgets/radio_settings_dialog.dart b/lib/features/settings/view/widgets/radio_settings_dialog.dart index 47c337c..c2d5d4c 100644 --- a/lib/features/settings/view/widgets/radio_settings_dialog.dart +++ b/lib/features/settings/view/widgets/radio_settings_dialog.dart @@ -4,7 +4,9 @@ import 'package:paperless_mobile/generated/l10n.dart'; class RadioSettingsDialog extends StatefulWidget { final List> options; final T initialValue; - final Widget? title; + final String? titleText; + final String? descriptionText; + final Widget? footer; final Widget? confirmButton; final Widget? cancelButton; @@ -12,9 +14,11 @@ class RadioSettingsDialog extends StatefulWidget { super.key, required this.options, required this.initialValue, - this.title, + this.titleText, this.confirmButton, this.cancelButton, + this.descriptionText, + this.footer, }); @override @@ -43,10 +47,16 @@ class _RadioSettingsDialogState extends State> { onPressed: () => Navigator.pop(context, _groupValue), child: Text(S.of(context).genericActionOkLabel)), ], - title: widget.title, + title: widget.titleText != null ? Text(widget.titleText!) : null, content: Column( mainAxisSize: MainAxisSize.min, - children: widget.options.map(_buildOptionListTile).toList(), + children: [ + if (widget.descriptionText != null) + Text(widget.descriptionText!, + style: Theme.of(context).textTheme.bodySmall), + ...widget.options.map(_buildOptionListTile), + if (widget.footer != null) widget.footer!, + ], ), ); } diff --git a/lib/features/settings/view/widgets/theme_mode_setting.dart b/lib/features/settings/view/widgets/theme_mode_setting.dart index 4430e2e..3c573ca 100644 --- a/lib/features/settings/view/widgets/theme_mode_setting.dart +++ b/lib/features/settings/view/widgets/theme_mode_setting.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -19,6 +19,11 @@ class ThemeModeSetting extends StatelessWidget { onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( + titleText: S.of(context).settingsPageAppearanceSettingTitle, + initialValue: context + .read() + .state + .preferredThemeMode, options: [ RadioOption( value: ThemeMode.system, @@ -38,14 +43,11 @@ class ThemeModeSetting extends StatelessWidget { S.of(context).settingsPageAppearanceSettingDarkThemeLabel, ) ], - initialValue: context - .read() - .state - .preferredThemeMode, - title: Text(S.of(context).settingsPageAppearanceSettingTitle), ), ).then((value) { - return context.read().setThemeMode(value); + if (value != null) { + context.read().setThemeMode(value); + } }), ); }, diff --git a/lib/features/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart new file mode 100644 index 0000000..1edb7fd --- /dev/null +++ b/lib/features/similar_documents/cubit/similar_documents_cubit.dart @@ -0,0 +1,39 @@ +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/features/paged_document_view/paged_documents_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; + +part 'similar_documents_state.dart'; + +class SimilarDocumentsCubit extends Cubit + with PagedDocumentsMixin { + final int documentId; + + @override + final PaperlessDocumentsApi api; + + @override + final DocumentChangedNotifier notifier; + + SimilarDocumentsCubit( + this.api, + this.notifier, { + required this.documentId, + }) : super(const SimilarDocumentsState()) { + notifier.subscribe( + this, + onDeleted: remove, + onUpdated: replace, + ); + } + + Future initialize() async { + if (!state.hasLoaded) { + await updateFilter( + filter: state.filter.copyWith(moreLike: () => documentId), + ); + emit(state.copyWith(hasLoaded: true)); + } + } +} diff --git a/lib/features/similar_documents/cubit/similar_documents_state.dart b/lib/features/similar_documents/cubit/similar_documents_state.dart new file mode 100644 index 0000000..75b683e --- /dev/null +++ b/lib/features/similar_documents/cubit/similar_documents_state.dart @@ -0,0 +1,47 @@ +part of 'similar_documents_cubit.dart'; + +class SimilarDocumentsState extends PagedDocumentsState { + const SimilarDocumentsState({ + super.filter, + super.hasLoaded, + super.isLoading, + super.value, + }); + + @override + List get props => [ + filter, + hasLoaded, + isLoading, + value, + ]; + + @override + SimilarDocumentsState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + value: value, + filter: filter, + ); + } + + SimilarDocumentsState copyWith({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return SimilarDocumentsState( + hasLoaded: hasLoaded ?? this.hasLoaded, + isLoading: isLoading ?? this.isLoading, + value: value ?? this.value, + filter: filter ?? this.filter, + ); + } +} diff --git a/lib/features/similar_documents/view/similar_documents_view.dart b/lib/features/similar_documents/view/similar_documents_view.dart new file mode 100644 index 0000000..0092e44 --- /dev/null +++ b/lib/features/similar_documents/view/similar_documents_view.dart @@ -0,0 +1,100 @@ +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/widgets/hint_card.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/items/document_list_item.dart'; +import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; + +class SimilarDocumentsView extends StatefulWidget { + const SimilarDocumentsView({super.key}); + + @override + State createState() => _SimilarDocumentsViewState(); +} + +class _SimilarDocumentsViewState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_listenForLoadNewData); + try { + context.read().initialize(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + + @override + void dispose() { + _scrollController.removeListener(_listenForLoadNewData); + super.dispose(); + } + + void _listenForLoadNewData() async { + final currState = context.read().state; + if (_scrollController.offset >= + _scrollController.position.maxScrollExtent * 0.75 && + !currState.isLoading && + !currState.isLastPageLoaded) { + try { + await context.read().loadMore(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.hasLoaded && !state.isLoading && state.documents.isEmpty) { + return DocumentsEmptyState( + state: state, + onReset: () => context.read().updateFilter( + filter: DocumentFilter.initial.copyWith( + moreLike: () => + context.read().documentId, + ), + ), + ); + } + + return BlocBuilder( + builder: (context, connectivity) { + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + enableHeroAnimation: false, + onTap: (document) { + Navigator.pushNamed( + context, + DocumentDetailsRoute.routeName, + arguments: DocumentDetailsRouteArguments( + document: document, + isLabelClickable: false, + ), + ); + }, + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/tasks/cubit/tasks_cubit.dart b/lib/features/tasks/cubit/tasks_cubit.dart deleted file mode 100644 index a5056f3..0000000 --- a/lib/features/tasks/cubit/tasks_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -part 'tasks_state.dart'; - -class TasksCubit extends Cubit { - TasksCubit() : super(TasksInitial()); -} diff --git a/lib/features/tasks/cubit/tasks_state.dart b/lib/features/tasks/cubit/tasks_state.dart deleted file mode 100644 index d4ae6d8..0000000 --- a/lib/features/tasks/cubit/tasks_state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'tasks_cubit.dart'; - -abstract class TasksState extends Equatable { - const TasksState(); - - @override - List get props => []; -} - -class TasksInitial extends TasksState {} diff --git a/lib/helpers/file_helpers.dart b/lib/helpers/file_helpers.dart new file mode 100644 index 0000000..6c4577f --- /dev/null +++ b/lib/helpers/file_helpers.dart @@ -0,0 +1,3 @@ +String extractFilenameFromPath(String path) { + return path.split(RegExp('[./]')).reversed.skip(1).first; +} diff --git a/lib/helpers/format_helpers.dart b/lib/helpers/format_helpers.dart new file mode 100644 index 0000000..d93ca57 --- /dev/null +++ b/lib/helpers/format_helpers.dart @@ -0,0 +1,15 @@ +import 'dart:math'; + +String formatMaxCount(int? count, [int maxCount = 99]) { + if ((count ?? 0) > maxCount) { + return "$maxCount+"; + } + return (count ?? 0).toString(); +} + +String formatBytes(int bytes, int decimals) { + if (bytes <= 0) return "0 B"; + const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + var i = (log(bytes) / log(1024)).floor(); + return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + ' ' + suffixes[i]; +} diff --git a/lib/helpers/image_helpers.dart b/lib/helpers/image_helpers.dart new file mode 100644 index 0000000..05e8de7 --- /dev/null +++ b/lib/helpers/image_helpers.dart @@ -0,0 +1,38 @@ +// 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/util.dart b/lib/helpers/message_helpers.dart similarity index 55% rename from lib/util.dart rename to lib/helpers/message_helpers.dart index 6870bf6..3e4c8bb 100644 --- a/lib/util.dart +++ b/lib/helpers/message_helpers.dart @@ -1,22 +1,10 @@ -import 'dart:async'; import 'dart:developer'; -import 'dart:io'; -import 'dart:ui'; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/core/service/github_issue_service.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:permission_handler/permission_handler.dart'; - -final dateFormat = DateFormat("yyyy-MM-dd"); -final GlobalKey rootScaffoldKey = GlobalKey(); class SnackBarActionConfig { final String label; @@ -121,63 +109,3 @@ void showErrorMessage( time: DateTime.now(), ); } - -bool isNotNull(dynamic value) { - return value != null; -} - -String formatDate(DateTime date) { - return dateFormat.format(date); -} - -String? formatDateNullable(DateTime? date) { - if (date == null) return null; - return dateFormat.format(date); -} - -String extractFilenameFromPath(String path) { - return path.split(RegExp('[./]')).reversed.skip(1).first; -} - -// Taken from https://github.com/flutter/flutter/issues/26127#issuecomment-782083060 -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; -} - -Future askForPermission(Permission permission) async { - final status = await permission.request(); - log("Permission requested, new status is $status"); - // If user has permanently declined permission, open settings. - if (status == PermissionStatus.permanentlyDenied) { - await openAppSettings(); - } - - return status == PermissionStatus.granted; -} diff --git a/lib/helpers/permission_helpers.dart b/lib/helpers/permission_helpers.dart new file mode 100644 index 0000000..a849994 --- /dev/null +++ b/lib/helpers/permission_helpers.dart @@ -0,0 +1,14 @@ +import 'dart:developer'; + +import 'package:permission_handler/permission_handler.dart'; + +Future askForPermission(Permission permission) async { + final status = await permission.request(); + log("Permission requested, new status is $status"); + // If user has permanently declined permission, open settings. + if (status == PermissionStatus.permanentlyDenied) { + await openAppSettings(); + } + + return status == PermissionStatus.granted; +} diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 204cc91..58842a3 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -6,6 +6,10 @@ "name": {} } }, + "accountSettingsAddAnotherAccount": "Add another account", + "@accountSettingsAddAnotherAccount": {}, + "accountSettingsTitle": "Account", + "@accountSettingsTitle": {}, "addCorrespondentPageTitle": "Nový korespondent", "@addCorrespondentPageTitle": {}, "addDocumentTypePageTitle": "Nový typ dokumentu", @@ -44,6 +48,10 @@ "@bottomNavLabelsPageLabel": {}, "bottomNavScannerPageLabel": "Skener", "@bottomNavScannerPageLabel": {}, + "colorSchemeOptionClassic": "Klasicky", + "@colorSchemeOptionClassic": {}, + "colorSchemeOptionDynamic": "Dynamicky", + "@colorSchemeOptionDynamic": {}, "correspondentFormFieldSearchHintText": "Začni psát...", "@correspondentFormFieldSearchHintText": {}, "deleteViewDialogContentText": "Opravdu chceš tento náhled smazat?", @@ -64,23 +72,23 @@ "@documentDeleteSuccessMessage": {}, "documentDetailsPageAssignAsnButtonLabel": "Přiřadit", "@documentDetailsPageAssignAsnButtonLabel": {}, - "documentDetailsPageDeleteTooltip": "Delete", + "documentDetailsPageDeleteTooltip": "Smazat", "@documentDetailsPageDeleteTooltip": {}, - "documentDetailsPageDownloadTooltip": "Download", + "documentDetailsPageDownloadTooltip": "Stáhnout", "@documentDetailsPageDownloadTooltip": {}, - "documentDetailsPageEditTooltip": "Edit", + "documentDetailsPageEditTooltip": "Upravit", "@documentDetailsPageEditTooltip": {}, "documentDetailsPageLoadFullContentLabel": "Načíst celý obsah", "@documentDetailsPageLoadFullContentLabel": {}, - "documentDetailsPageNoPdfViewerFoundErrorMessage": "No app to display PDF files found!", + "documentDetailsPageNoPdfViewerFoundErrorMessage": "Aplikace pro otevírání PDF souborů nenalezena.", "@documentDetailsPageNoPdfViewerFoundErrorMessage": {}, - "documentDetailsPageOpenInSystemViewerTooltip": "Open in system viewer", + "documentDetailsPageOpenInSystemViewerTooltip": "Otevřít v systémovém prohlížeči", "@documentDetailsPageOpenInSystemViewerTooltip": {}, - "documentDetailsPageOpenPdfPermissionDeniedErrorMessage": "Could not open file: Permission denied.", + "documentDetailsPageOpenPdfPermissionDeniedErrorMessage": "Soubor nelze otevřít: přístup zamítnut.", "@documentDetailsPageOpenPdfPermissionDeniedErrorMessage": {}, - "documentDetailsPagePreviewTooltip": "Preview", + "documentDetailsPagePreviewTooltip": "Náhled", "@documentDetailsPagePreviewTooltip": {}, - "documentDetailsPageShareTooltip": "Share", + "documentDetailsPageShareTooltip": "Sdílet", "@documentDetailsPageShareTooltip": {}, "documentDetailsPageSimilarDocumentsLabel": "Podobné dokumenty", "@documentDetailsPageSimilarDocumentsLabel": {}, @@ -90,6 +98,8 @@ "@documentDetailsPageTabMetaDataLabel": {}, "documentDetailsPageTabOverviewLabel": "Přehled", "@documentDetailsPageTabOverviewLabel": {}, + "documentDetailsPageTabSimilarDocumentsLabel": "Podobné dokumenty", + "@documentDetailsPageTabSimilarDocumentsLabel": {}, "documentDocumentTypePropertyLabel": "Typ dokumentu", "@documentDocumentTypePropertyLabel": {}, "documentDownloadSuccessMessage": "Dokument úspěšně stažen.", @@ -142,6 +152,16 @@ "@documentScannerPageUploadButtonTooltip": {}, "documentScannerPageUploadFromThisDeviceButtonLabel": "Nahrát jeden dokument z tohoto zařízení", "@documentScannerPageUploadFromThisDeviceButtonLabel": {}, + "documentSearchHistory": "Historie", + "@documentSearchHistory": {}, + "documentSearchNoMatchesFound": "No matches found.", + "@documentSearchNoMatchesFound": {}, + "documentSearchPageRemoveFromHistory": "Odstranit z historie vyhledávání?", + "@documentSearchPageRemoveFromHistory": {}, + "documentSearchResults": "Výsledky", + "@documentSearchResults": {}, + "documentSearchSearchDocuments": "Search documents", + "@documentSearchSearchDocuments": {}, "documentsEmptyStateResetFilterLabel": "Zrušit", "@documentsEmptyStateResetFilterLabel": {}, "documentsFilterPageAdvancedLabel": "Rozšířené", @@ -358,6 +378,8 @@ "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Zrušit", "@genericActionCancelLabel": {}, + "genericActionCloseLabel": "Close", + "@genericActionCloseLabel": {}, "genericActionCreateLabel": "Vytvořit", "@genericActionCreateLabel": {}, "genericActionDeleteLabel": "Smazat", @@ -376,6 +398,10 @@ "@genericActionUploadLabel": {}, "genericMessageOfflineText": "Jste offline.", "@genericMessageOfflineText": {}, + "inboxActionAssignAsn": "Assign ASN", + "@inboxActionAssignAsn": {}, + "inboxActionDeleteDocument": "Delete document", + "@inboxActionDeleteDocument": {}, "inboxPageAssignAsnLabel": "Assign ASN", "@inboxPageAssignAsnLabel": {}, "inboxPageDocumentRemovedMessageText": "Dokument odstraněn z inboxu.", @@ -546,14 +572,26 @@ "@savedViewNameLabel": {}, "savedViewsEmptyStateText": "Vytvoře si různé náhledy pro rychlé filtrování dokumentů.", "@savedViewsEmptyStateText": {}, + "savedViewsFiltersSetCount": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "@savedViewsFiltersSetCount": { + "placeholders": { + "count": {} + } + }, "savedViewShowInSidebarLabel": "Zobrazit v postranní liště", "@savedViewShowInSidebarLabel": {}, "savedViewShowOnDashboardLabel": "Zobrazit na hlavním panelu", "@savedViewShowOnDashboardLabel": {}, "savedViewsLabel": "Uložené náhledy", "@savedViewsLabel": {}, + "scannerPageClearAllLabel": "Clear all", + "@scannerPageClearAllLabel": {}, "scannerPageImagePreviewTitle": "Sken", "@scannerPageImagePreviewTitle": {}, + "scannerPagePreviewLabel": "Preview", + "@scannerPagePreviewLabel": {}, + "scannerPageUploadLabel": "Upload", + "@scannerPageUploadLabel": {}, "serverInformationPaperlessVersionText": "Verze Paperless serveru", "@serverInformationPaperlessVersionText": {}, "settingsPageAppearanceSettingDarkThemeLabel": "Tmavý vzhled", @@ -568,6 +606,12 @@ "@settingsPageApplicationSettingsDescriptionText": {}, "settingsPageApplicationSettingsLabel": "Aplikace", "@settingsPageApplicationSettingsLabel": {}, + "settingsPageColorSchemeSettingDialogDescription": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "@settingsPageColorSchemeSettingDialogDescription": {}, + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "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.", + "@settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": {}, + "settingsPageColorSchemeSettingLabel": "Barvy", + "@settingsPageColorSchemeSettingLabel": {}, "settingsPageLanguageSettingLabel": "Jazyk", "@settingsPageLanguageSettingLabel": {}, "settingsPageSecuritySettingsDescriptionText": "Biometrické ověření", @@ -584,6 +628,10 @@ "@settingsThemeModeLightLabel": {}, "settingsThemeModeSystemLabel": "Systémový", "@settingsThemeModeSystemLabel": {}, + "sortDocumentAscending": "Vzestupně", + "@sortDocumentAscending": {}, + "sortDocumentDescending": "Sestupně", + "@sortDocumentDescending": {}, "storagePathParameterDayLabel": "den", "@storagePathParameterDayLabel": {}, "storagePathParameterMonthLabel": "měsíc", diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index d2ddc85..1e29e7f 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -6,6 +6,10 @@ "name": {} } }, + "accountSettingsAddAnotherAccount": "Einen Account hinzufügen", + "@accountSettingsAddAnotherAccount": {}, + "accountSettingsTitle": "Account", + "@accountSettingsTitle": {}, "addCorrespondentPageTitle": "Neuer Korrespondent", "@addCorrespondentPageTitle": {}, "addDocumentTypePageTitle": "Neuer Dokumenttyp", @@ -40,10 +44,14 @@ "@bottomNavDocumentsPageLabel": {}, "bottomNavInboxPageLabel": "Posteingang", "@bottomNavInboxPageLabel": {}, - "bottomNavLabelsPageLabel": "Kennzeichnungen", + "bottomNavLabelsPageLabel": "Labels", "@bottomNavLabelsPageLabel": {}, "bottomNavScannerPageLabel": "Scanner", "@bottomNavScannerPageLabel": {}, + "colorSchemeOptionClassic": "Klassisch", + "@colorSchemeOptionClassic": {}, + "colorSchemeOptionDynamic": "Dynamisch", + "@colorSchemeOptionDynamic": {}, "correspondentFormFieldSearchHintText": "Beginne zu tippen...", "@correspondentFormFieldSearchHintText": {}, "deleteViewDialogContentText": "Möchtest Du diese Ansicht wirklich löschen?", @@ -90,6 +98,8 @@ "@documentDetailsPageTabMetaDataLabel": {}, "documentDetailsPageTabOverviewLabel": "Übersicht", "@documentDetailsPageTabOverviewLabel": {}, + "documentDetailsPageTabSimilarDocumentsLabel": "Ähnliche Dokumente", + "@documentDetailsPageTabSimilarDocumentsLabel": {}, "documentDocumentTypePropertyLabel": "Dokumenttyp", "@documentDocumentTypePropertyLabel": {}, "documentDownloadSuccessMessage": "Dokument erfolgreich heruntergeladen.", @@ -142,6 +152,16 @@ "@documentScannerPageUploadButtonTooltip": {}, "documentScannerPageUploadFromThisDeviceButtonLabel": "Lade ein Dokument von diesem Gerät hoch", "@documentScannerPageUploadFromThisDeviceButtonLabel": {}, + "documentSearchHistory": "Verlauf", + "@documentSearchHistory": {}, + "documentSearchNoMatchesFound": "Keine Treffer.", + "@documentSearchNoMatchesFound": {}, + "documentSearchPageRemoveFromHistory": "Aus dem Suchverlauf entfernen?", + "@documentSearchPageRemoveFromHistory": {}, + "documentSearchResults": "Ergebnisse", + "@documentSearchResults": {}, + "documentSearchSearchDocuments": "Durchsuche Dokumente", + "@documentSearchSearchDocuments": {}, "documentsEmptyStateResetFilterLabel": "Filter zurücksetzen", "@documentsEmptyStateResetFilterLabel": {}, "documentsFilterPageAdvancedLabel": "Erweitert", @@ -256,7 +276,7 @@ "@errorMessageDocumentUploadFailed": {}, "errorMessageInvalidClientCertificateConfiguration": "Ungültiges Zertifikat oder fehlende Passphrase, bitte versuche es erneut.", "@errorMessageInvalidClientCertificateConfiguration": {}, - "errorMessageLoadSavedViewsError": "Gespeicherte Ansichten konnten nicht geladen werden.", + "errorMessageLoadSavedViewsError": "Ansichten konnten nicht geladen werden.", "@errorMessageLoadSavedViewsError": {}, "errorMessageMissingClientCertificate": "Ein Client Zerfitikat wurde erwartet, aber nicht gesendet. Bitte konfiguriere ein gültiges Zertifikat.", "@errorMessageMissingClientCertificate": {}, @@ -358,6 +378,8 @@ "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Abbrechen", "@genericActionCancelLabel": {}, + "genericActionCloseLabel": "Schließen", + "@genericActionCloseLabel": {}, "genericActionCreateLabel": "Erstellen", "@genericActionCreateLabel": {}, "genericActionDeleteLabel": "Löschen", @@ -376,6 +398,10 @@ "@genericActionUploadLabel": {}, "genericMessageOfflineText": "Du bist offline.", "@genericMessageOfflineText": {}, + "inboxActionAssignAsn": "ASN zuweisen", + "@inboxActionAssignAsn": {}, + "inboxActionDeleteDocument": "Dokument löschen", + "@inboxActionDeleteDocument": {}, "inboxPageAssignAsnLabel": "ASN zuweisen", "@inboxPageAssignAsnLabel": {}, "inboxPageDocumentRemovedMessageText": "Dokument aus Posteingang entfernt.", @@ -546,14 +572,26 @@ "@savedViewNameLabel": {}, "savedViewsEmptyStateText": "Lege Ansichten an, um Dokumente schneller zu finden.", "@savedViewsEmptyStateText": {}, + "savedViewsFiltersSetCount": "{count, plural, zero{{count} Filter gesetzt} one{{count} Filter gesetzt} other{{count} Filter gesetzt}}", + "@savedViewsFiltersSetCount": { + "placeholders": { + "count": {} + } + }, "savedViewShowInSidebarLabel": "In Seitenleiste zeigen", "@savedViewShowInSidebarLabel": {}, "savedViewShowOnDashboardLabel": "Auf Startseite zeigen", "@savedViewShowOnDashboardLabel": {}, - "savedViewsLabel": "Gespeicherte Ansichten", + "savedViewsLabel": "Ansichten", "@savedViewsLabel": {}, + "scannerPageClearAllLabel": "Alle löschen", + "@scannerPageClearAllLabel": {}, "scannerPageImagePreviewTitle": "Aufnahme", "@scannerPageImagePreviewTitle": {}, + "scannerPagePreviewLabel": "Vorschau", + "@scannerPagePreviewLabel": {}, + "scannerPageUploadLabel": "Hochladen", + "@scannerPageUploadLabel": {}, "serverInformationPaperlessVersionText": "Paperless Server-Version", "@serverInformationPaperlessVersionText": {}, "settingsPageAppearanceSettingDarkThemeLabel": "Dunkler Modus", @@ -568,6 +606,12 @@ "@settingsPageApplicationSettingsDescriptionText": {}, "settingsPageApplicationSettingsLabel": "Anwendung", "@settingsPageApplicationSettingsLabel": {}, + "settingsPageColorSchemeSettingDialogDescription": "Wähle zwischen einem klassischen Farbschema, das vom traditionellen Paperless-Grün inspiriert ist, oder einem dynamische Farbschema basierend auf den Systemfarben.", + "@settingsPageColorSchemeSettingDialogDescription": {}, + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamische Farbgebung wird nur von Geräten mit Android 12 und höher unterstützt. Das Auswählen des dynamischen Farbschemas hat für Geräte unter Android 12 womöglich keinen Effekt.", + "@settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": {}, + "settingsPageColorSchemeSettingLabel": "Farben", + "@settingsPageColorSchemeSettingLabel": {}, "settingsPageLanguageSettingLabel": "Sprache", "@settingsPageLanguageSettingLabel": {}, "settingsPageSecuritySettingsDescriptionText": "Biometrische Authentifizierung", @@ -584,6 +628,10 @@ "@settingsThemeModeLightLabel": {}, "settingsThemeModeSystemLabel": "System", "@settingsThemeModeSystemLabel": {}, + "sortDocumentAscending": "Aufsteigend", + "@sortDocumentAscending": {}, + "sortDocumentDescending": "Absteigend", + "@sortDocumentDescending": {}, "storagePathParameterDayLabel": "Tag", "@storagePathParameterDayLabel": {}, "storagePathParameterMonthLabel": "Monat", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1ba5eed..9d5124c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -6,6 +6,10 @@ "name": {} } }, + "accountSettingsAddAnotherAccount": "Add another account", + "@accountSettingsAddAnotherAccount": {}, + "accountSettingsTitle": "Account", + "@accountSettingsTitle": {}, "addCorrespondentPageTitle": "New Correspondent", "@addCorrespondentPageTitle": {}, "addDocumentTypePageTitle": "New Document Type", @@ -44,6 +48,10 @@ "@bottomNavLabelsPageLabel": {}, "bottomNavScannerPageLabel": "Scanner", "@bottomNavScannerPageLabel": {}, + "colorSchemeOptionClassic": "Classic", + "@colorSchemeOptionClassic": {}, + "colorSchemeOptionDynamic": "Dynamic", + "@colorSchemeOptionDynamic": {}, "correspondentFormFieldSearchHintText": "Start typing...", "@correspondentFormFieldSearchHintText": {}, "deleteViewDialogContentText": "Do you really want to delete this view?", @@ -90,6 +98,8 @@ "@documentDetailsPageTabMetaDataLabel": {}, "documentDetailsPageTabOverviewLabel": "Overview", "@documentDetailsPageTabOverviewLabel": {}, + "documentDetailsPageTabSimilarDocumentsLabel": "Similar Documents", + "@documentDetailsPageTabSimilarDocumentsLabel": {}, "documentDocumentTypePropertyLabel": "Document Type", "@documentDocumentTypePropertyLabel": {}, "documentDownloadSuccessMessage": "Document successfully downloaded.", @@ -142,6 +152,16 @@ "@documentScannerPageUploadButtonTooltip": {}, "documentScannerPageUploadFromThisDeviceButtonLabel": "Upload a document from this device", "@documentScannerPageUploadFromThisDeviceButtonLabel": {}, + "documentSearchHistory": "History", + "@documentSearchHistory": {}, + "documentSearchNoMatchesFound": "No matches found.", + "@documentSearchNoMatchesFound": {}, + "documentSearchPageRemoveFromHistory": "Remove from search history?", + "@documentSearchPageRemoveFromHistory": {}, + "documentSearchResults": "Results", + "@documentSearchResults": {}, + "documentSearchSearchDocuments": "Search documents", + "@documentSearchSearchDocuments": {}, "documentsEmptyStateResetFilterLabel": "Reset filter", "@documentsEmptyStateResetFilterLabel": {}, "documentsFilterPageAdvancedLabel": "Advanced", @@ -256,7 +276,7 @@ "@errorMessageDocumentUploadFailed": {}, "errorMessageInvalidClientCertificateConfiguration": "Invalid certificate or missing passphrase, please try again", "@errorMessageInvalidClientCertificateConfiguration": {}, - "errorMessageLoadSavedViewsError": "Could not load saved views.", + "errorMessageLoadSavedViewsError": "Could not load views.", "@errorMessageLoadSavedViewsError": {}, "errorMessageMissingClientCertificate": "A client certificate was expected but not sent. Please provide a valid client certificate.", "@errorMessageMissingClientCertificate": {}, @@ -358,6 +378,8 @@ "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "Cancel", "@genericActionCancelLabel": {}, + "genericActionCloseLabel": "Close", + "@genericActionCloseLabel": {}, "genericActionCreateLabel": "Create", "@genericActionCreateLabel": {}, "genericActionDeleteLabel": "Delete", @@ -376,6 +398,10 @@ "@genericActionUploadLabel": {}, "genericMessageOfflineText": "You're offline.", "@genericMessageOfflineText": {}, + "inboxActionAssignAsn": "Assign ASN", + "@inboxActionAssignAsn": {}, + "inboxActionDeleteDocument": "Delete document", + "@inboxActionDeleteDocument": {}, "inboxPageAssignAsnLabel": "Assign ASN", "@inboxPageAssignAsnLabel": {}, "inboxPageDocumentRemovedMessageText": "Document removed from inbox.", @@ -546,14 +572,26 @@ "@savedViewNameLabel": {}, "savedViewsEmptyStateText": "Create views to quickly filter your documents.", "@savedViewsEmptyStateText": {}, + "savedViewsFiltersSetCount": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "@savedViewsFiltersSetCount": { + "placeholders": { + "count": {} + } + }, "savedViewShowInSidebarLabel": "Show in sidebar", "@savedViewShowInSidebarLabel": {}, "savedViewShowOnDashboardLabel": "Show on dashboard", "@savedViewShowOnDashboardLabel": {}, - "savedViewsLabel": "Saved Views", + "savedViewsLabel": "Views", "@savedViewsLabel": {}, + "scannerPageClearAllLabel": "Clear all", + "@scannerPageClearAllLabel": {}, "scannerPageImagePreviewTitle": "Scan", "@scannerPageImagePreviewTitle": {}, + "scannerPagePreviewLabel": "Preview", + "@scannerPagePreviewLabel": {}, + "scannerPageUploadLabel": "Upload", + "@scannerPageUploadLabel": {}, "serverInformationPaperlessVersionText": "Paperless server version", "@serverInformationPaperlessVersionText": {}, "settingsPageAppearanceSettingDarkThemeLabel": "Dark Theme", @@ -568,6 +606,12 @@ "@settingsPageApplicationSettingsDescriptionText": {}, "settingsPageApplicationSettingsLabel": "Application", "@settingsPageApplicationSettingsLabel": {}, + "settingsPageColorSchemeSettingDialogDescription": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "@settingsPageColorSchemeSettingDialogDescription": {}, + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "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.", + "@settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": {}, + "settingsPageColorSchemeSettingLabel": "Colors", + "@settingsPageColorSchemeSettingLabel": {}, "settingsPageLanguageSettingLabel": "Language", "@settingsPageLanguageSettingLabel": {}, "settingsPageSecuritySettingsDescriptionText": "Biometric authentication", @@ -584,6 +628,10 @@ "@settingsThemeModeLightLabel": {}, "settingsThemeModeSystemLabel": "System", "@settingsThemeModeSystemLabel": {}, + "sortDocumentAscending": "Ascending", + "@sortDocumentAscending": {}, + "sortDocumentDescending": "Descending", + "@sortDocumentDescending": {}, "storagePathParameterDayLabel": "day", "@storagePathParameterDayLabel": {}, "storagePathParameterMonthLabel": "month", diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb new file mode 100644 index 0000000..6b6b654 --- /dev/null +++ b/lib/l10n/intl_pl.arb @@ -0,0 +1,657 @@ +{ + "@@locale": "pl", + "aboutDialogDevelopedByText": "Developed by {name}", + "@aboutDialogDevelopedByText": { + "placeholders": { + "name": {} + } + }, + "accountSettingsAddAnotherAccount": "Add another account", + "@accountSettingsAddAnotherAccount": {}, + "accountSettingsTitle": "Account", + "@accountSettingsTitle": {}, + "addCorrespondentPageTitle": "New Correspondent", + "@addCorrespondentPageTitle": {}, + "addDocumentTypePageTitle": "Nowy rodzaj dokumentu", + "@addDocumentTypePageTitle": {}, + "addStoragePathPageTitle": "New Storage Path", + "@addStoragePathPageTitle": {}, + "addTagPageTitle": "Nowy tag", + "@addTagPageTitle": {}, + "appDrawerAboutInfoLoadingText": "Retrieving application information...", + "@appDrawerAboutInfoLoadingText": {}, + "appDrawerAboutLabel": "O aplikacji", + "@appDrawerAboutLabel": {}, + "appDrawerHeaderLoggedInAsText": "Zalogowano jako", + "@appDrawerHeaderLoggedInAsText": {}, + "appDrawerLogoutLabel": "Disconnect", + "@appDrawerLogoutLabel": {}, + "appDrawerReportBugLabel": "Report a Bug", + "@appDrawerReportBugLabel": {}, + "appDrawerSettingsLabel": "Ustawienia", + "@appDrawerSettingsLabel": {}, + "appSettingsBiometricAuthenticationDescriptionText": "Authenticate on app start", + "@appSettingsBiometricAuthenticationDescriptionText": {}, + "appSettingsBiometricAuthenticationLabel": "Biometric authentication", + "@appSettingsBiometricAuthenticationLabel": {}, + "appSettingsDisableBiometricAuthenticationReasonText": "Authenticate to disable biometric authentication", + "@appSettingsDisableBiometricAuthenticationReasonText": {}, + "appSettingsEnableBiometricAuthenticationReasonText": "Authenticate to enable biometric authentication", + "@appSettingsEnableBiometricAuthenticationReasonText": {}, + "appTitleText": "Paperless Mobile", + "@appTitleText": {}, + "bottomNavDocumentsPageLabel": "Documents", + "@bottomNavDocumentsPageLabel": {}, + "bottomNavInboxPageLabel": "Skrzynka odbiorcza", + "@bottomNavInboxPageLabel": {}, + "bottomNavLabelsPageLabel": "Labels", + "@bottomNavLabelsPageLabel": {}, + "bottomNavScannerPageLabel": "Scanner", + "@bottomNavScannerPageLabel": {}, + "colorSchemeOptionClassic": "Classic", + "@colorSchemeOptionClassic": {}, + "colorSchemeOptionDynamic": "Dynamic", + "@colorSchemeOptionDynamic": {}, + "correspondentFormFieldSearchHintText": "Zacznij pisać...", + "@correspondentFormFieldSearchHintText": {}, + "deleteViewDialogContentText": "Do you really want to delete this view?", + "@deleteViewDialogContentText": {}, + "deleteViewDialogTitleText": "Delete view ", + "@deleteViewDialogTitleText": {}, + "documentAddedPropertyLabel": "Added at", + "@documentAddedPropertyLabel": {}, + "documentArchiveSerialNumberPropertyLongLabel": "Numer Seryjny Archiwum", + "@documentArchiveSerialNumberPropertyLongLabel": {}, + "documentArchiveSerialNumberPropertyShortLabel": "ASN", + "@documentArchiveSerialNumberPropertyShortLabel": {}, + "documentCorrespondentPropertyLabel": "Correspondent", + "@documentCorrespondentPropertyLabel": {}, + "documentCreatedPropertyLabel": "Created at", + "@documentCreatedPropertyLabel": {}, + "documentDeleteSuccessMessage": "Dokument pomyślnie usunięty.", + "@documentDeleteSuccessMessage": {}, + "documentDetailsPageAssignAsnButtonLabel": "Assign", + "@documentDetailsPageAssignAsnButtonLabel": {}, + "documentDetailsPageDeleteTooltip": "Usuń", + "@documentDetailsPageDeleteTooltip": {}, + "documentDetailsPageDownloadTooltip": "Pobierz", + "@documentDetailsPageDownloadTooltip": {}, + "documentDetailsPageEditTooltip": "Edytuj", + "@documentDetailsPageEditTooltip": {}, + "documentDetailsPageLoadFullContentLabel": "Load full content", + "@documentDetailsPageLoadFullContentLabel": {}, + "documentDetailsPageNoPdfViewerFoundErrorMessage": "Nie znaleziono aplikacji do wyświetlania plików PDF", + "@documentDetailsPageNoPdfViewerFoundErrorMessage": {}, + "documentDetailsPageOpenInSystemViewerTooltip": "Otwórz w przeglądarce systemowej", + "@documentDetailsPageOpenInSystemViewerTooltip": {}, + "documentDetailsPageOpenPdfPermissionDeniedErrorMessage": "Nie można otworzyć pliku: ", + "@documentDetailsPageOpenPdfPermissionDeniedErrorMessage": {}, + "documentDetailsPagePreviewTooltip": "Podgląd", + "@documentDetailsPagePreviewTooltip": {}, + "documentDetailsPageShareTooltip": "Udostępnij", + "@documentDetailsPageShareTooltip": {}, + "documentDetailsPageSimilarDocumentsLabel": "Podobne Dokumenty", + "@documentDetailsPageSimilarDocumentsLabel": {}, + "documentDetailsPageTabContentLabel": "Treść", + "@documentDetailsPageTabContentLabel": {}, + "documentDetailsPageTabMetaDataLabel": "Meta dane", + "@documentDetailsPageTabMetaDataLabel": {}, + "documentDetailsPageTabOverviewLabel": "Przegląd", + "@documentDetailsPageTabOverviewLabel": {}, + "documentDetailsPageTabSimilarDocumentsLabel": "Podobne Dokumenty", + "@documentDetailsPageTabSimilarDocumentsLabel": {}, + "documentDocumentTypePropertyLabel": "Rodzaj dokumentu", + "@documentDocumentTypePropertyLabel": {}, + "documentDownloadSuccessMessage": "Document successfully downloaded.", + "@documentDownloadSuccessMessage": {}, + "documentEditPageSuggestionsLabel": "Suggestions: ", + "@documentEditPageSuggestionsLabel": {}, + "documentEditPageTitle": "Edytuj Dokument", + "@documentEditPageTitle": {}, + "documentFilterAdvancedLabel": "Advanced", + "@documentFilterAdvancedLabel": {}, + "documentFilterApplyFilterLabel": "Apply", + "@documentFilterApplyFilterLabel": {}, + "documentFilterQueryOptionsAsnLabel": "ASN", + "@documentFilterQueryOptionsAsnLabel": {}, + "documentFilterQueryOptionsExtendedLabel": "Extended", + "@documentFilterQueryOptionsExtendedLabel": {}, + "documentFilterQueryOptionsTitleAndContentLabel": "Tytuł i treść", + "@documentFilterQueryOptionsTitleAndContentLabel": {}, + "documentFilterQueryOptionsTitleLabel": "Tytuł", + "@documentFilterQueryOptionsTitleLabel": {}, + "documentFilterResetLabel": "Reset", + "@documentFilterResetLabel": {}, + "documentFilterSearchLabel": "Szukaj", + "@documentFilterSearchLabel": {}, + "documentFilterTitle": "Filter Documents", + "@documentFilterTitle": {}, + "documentMetaDataChecksumLabel": "Original MD5-Checksum", + "@documentMetaDataChecksumLabel": {}, + "documentMetaDataMediaFilenamePropertyLabel": "Media Filename", + "@documentMetaDataMediaFilenamePropertyLabel": {}, + "documentMetaDataOriginalFileSizeLabel": "Original File Size", + "@documentMetaDataOriginalFileSizeLabel": {}, + "documentMetaDataOriginalMimeTypeLabel": "Original MIME-Type", + "@documentMetaDataOriginalMimeTypeLabel": {}, + "documentModifiedPropertyLabel": "Modified at", + "@documentModifiedPropertyLabel": {}, + "documentPreviewPageTitle": "Podgląd", + "@documentPreviewPageTitle": {}, + "documentScannerPageAddScanButtonLabel": "Zeskanuj dokument", + "@documentScannerPageAddScanButtonLabel": {}, + "documentScannerPageEmptyStateText": "No documents scanned yet.", + "@documentScannerPageEmptyStateText": {}, + "documentScannerPageOrText": "lub", + "@documentScannerPageOrText": {}, + "documentScannerPageResetButtonTooltipText": "Delete all scans", + "@documentScannerPageResetButtonTooltipText": {}, + "documentScannerPageTitle": "Skanuj", + "@documentScannerPageTitle": {}, + "documentScannerPageUploadButtonTooltip": "Prześlij dokument z tego urządzenia", + "@documentScannerPageUploadButtonTooltip": {}, + "documentScannerPageUploadFromThisDeviceButtonLabel": "Upload a document from this device", + "@documentScannerPageUploadFromThisDeviceButtonLabel": {}, + "documentSearchHistory": "Historia", + "@documentSearchHistory": {}, + "documentSearchNoMatchesFound": "No matches found.", + "@documentSearchNoMatchesFound": {}, + "documentSearchPageRemoveFromHistory": "Usunąć z historii wyszukiwania?", + "@documentSearchPageRemoveFromHistory": {}, + "documentSearchResults": "Wyniki", + "@documentSearchResults": {}, + "documentSearchSearchDocuments": "Search documents", + "@documentSearchSearchDocuments": {}, + "documentsEmptyStateResetFilterLabel": "Reset filter", + "@documentsEmptyStateResetFilterLabel": {}, + "documentsFilterPageAdvancedLabel": "Advanced", + "@documentsFilterPageAdvancedLabel": {}, + "documentsFilterPageApplyFilterLabel": "Apply", + "@documentsFilterPageApplyFilterLabel": {}, + "documentsFilterPageDateRangeLastMonthLabel": "Last Month", + "@documentsFilterPageDateRangeLastMonthLabel": {}, + "documentsFilterPageDateRangeLastSevenDaysLabel": "Last 7 Days", + "@documentsFilterPageDateRangeLastSevenDaysLabel": {}, + "documentsFilterPageDateRangeLastThreeMonthsLabel": "Last 3 Months", + "@documentsFilterPageDateRangeLastThreeMonthsLabel": {}, + "documentsFilterPageDateRangeLastYearLabel": "Last Year", + "@documentsFilterPageDateRangeLastYearLabel": {}, + "documentsFilterPageQueryOptionsAsnLabel": "ASN", + "@documentsFilterPageQueryOptionsAsnLabel": {}, + "documentsFilterPageQueryOptionsExtendedLabel": "Extended", + "@documentsFilterPageQueryOptionsExtendedLabel": {}, + "documentsFilterPageQueryOptionsTitleAndContentLabel": "Title & Content", + "@documentsFilterPageQueryOptionsTitleAndContentLabel": {}, + "documentsFilterPageQueryOptionsTitleLabel": "Title", + "@documentsFilterPageQueryOptionsTitleLabel": {}, + "documentsFilterPageSearchLabel": "Szukaj", + "@documentsFilterPageSearchLabel": {}, + "documentsFilterPageTitle": "Filter Documents", + "@documentsFilterPageTitle": {}, + "documentsPageBulkDeleteSuccessfulText": "Dokument pomyślnie usunięty.", + "@documentsPageBulkDeleteSuccessfulText": {}, + "documentsPageEmptyStateNothingHereText": "There seems to be nothing here...", + "@documentsPageEmptyStateNothingHereText": {}, + "documentsPageEmptyStateOopsText": "Ups.", + "@documentsPageEmptyStateOopsText": {}, + "documentsPageNewDocumentAvailableText": "New document available!", + "@documentsPageNewDocumentAvailableText": {}, + "documentsPageOrderByLabel": "Order By", + "@documentsPageOrderByLabel": {}, + "documentsPageSelectionBulkDeleteDialogContinueText": "This action is irreversible. Do you wish to proceed anyway?", + "@documentsPageSelectionBulkDeleteDialogContinueText": {}, + "documentsPageSelectionBulkDeleteDialogTitle": "Potwierdź usunięcie", + "@documentsPageSelectionBulkDeleteDialogTitle": {}, + "documentsPageSelectionBulkDeleteDialogWarningTextMany": "Are you sure you want to delete the following documents?", + "@documentsPageSelectionBulkDeleteDialogWarningTextMany": {}, + "documentsPageSelectionBulkDeleteDialogWarningTextOne": "Are you sure you want to delete the following document?", + "@documentsPageSelectionBulkDeleteDialogWarningTextOne": {}, + "documentsPageTitle": "Documents", + "@documentsPageTitle": {}, + "documentsSelectedText": "selected", + "@documentsSelectedText": {}, + "documentStoragePathPropertyLabel": "Storage Path", + "@documentStoragePathPropertyLabel": {}, + "documentsUploadPageTitle": "Prepare document", + "@documentsUploadPageTitle": {}, + "documentTagsPropertyLabel": "Tagi", + "@documentTagsPropertyLabel": {}, + "documentTitlePropertyLabel": "Tytuł", + "@documentTitlePropertyLabel": {}, + "documentTypeFormFieldSearchHintText": "Zacznij pisać...", + "@documentTypeFormFieldSearchHintText": {}, + "documentUpdateSuccessMessage": "Dokument został pomyślnie zaktualizowany ", + "@documentUpdateSuccessMessage": {}, + "documentUploadFileNameLabel": "Nazwa Pliku", + "@documentUploadFileNameLabel": {}, + "documentUploadPageSynchronizeTitleAndFilenameLabel": "Synchronize title and filename", + "@documentUploadPageSynchronizeTitleAndFilenameLabel": {}, + "documentUploadProcessingSuccessfulReloadActionText": "Reload", + "@documentUploadProcessingSuccessfulReloadActionText": {}, + "documentUploadProcessingSuccessfulText": "Dokument pomyślnie przetworzony.", + "@documentUploadProcessingSuccessfulText": {}, + "documentUploadSuccessText": "Dokument pomyślnie przesłany, przetwarzam...", + "@documentUploadSuccessText": {}, + "editLabelPageConfirmDeletionDialogTitle": "Potwierdź usunięcie", + "@editLabelPageConfirmDeletionDialogTitle": {}, + "editLabelPageDeletionDialogText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?", + "@editLabelPageDeletionDialogText": {}, + "errorMessageAcknowledgeTasksError": "Could not acknowledge tasks.", + "@errorMessageAcknowledgeTasksError": {}, + "errorMessageAuthenticationFailed": "Authentication failed, please try again.", + "@errorMessageAuthenticationFailed": {}, + "errorMessageAutocompleteQueryError": "An error ocurred while trying to autocomplete your query.", + "@errorMessageAutocompleteQueryError": {}, + "errorMessageBiometricAuthenticationFailed": "Biometric authentication failed.", + "@errorMessageBiometricAuthenticationFailed": {}, + "errorMessageBiotmetricsNotSupported": "Biometric authentication not supported on this device.", + "@errorMessageBiotmetricsNotSupported": {}, + "errorMessageBulkActionFailed": "Could not bulk edit documents.", + "@errorMessageBulkActionFailed": {}, + "errorMessageCorrespondentCreateFailed": "Could not create correspondent, please try again.", + "@errorMessageCorrespondentCreateFailed": {}, + "errorMessageCorrespondentLoadFailed": "Could not load correspondents.", + "@errorMessageCorrespondentLoadFailed": {}, + "errorMessageCreateSavedViewError": "Could not create saved view, please try again.", + "@errorMessageCreateSavedViewError": {}, + "errorMessageDeleteSavedViewError": "Could not delete saved view, please try again", + "@errorMessageDeleteSavedViewError": {}, + "errorMessageDeviceOffline": "You are currently offline. Please make sure you are connected to the internet.", + "@errorMessageDeviceOffline": {}, + "errorMessageDocumentAsnQueryFailed": "Could not assign archive serial number.", + "@errorMessageDocumentAsnQueryFailed": {}, + "errorMessageDocumentDeleteFailed": "Could not delete document, please try again.", + "@errorMessageDocumentDeleteFailed": {}, + "errorMessageDocumentLoadFailed": "Could not load documents, please try again.", + "@errorMessageDocumentLoadFailed": {}, + "errorMessageDocumentPreviewFailed": "Could not load document preview.", + "@errorMessageDocumentPreviewFailed": {}, + "errorMessageDocumentTypeCreateFailed": "Could not create document, please try again.", + "@errorMessageDocumentTypeCreateFailed": {}, + "errorMessageDocumentTypeLoadFailed": "Could not load document types, please try again.", + "@errorMessageDocumentTypeLoadFailed": {}, + "errorMessageDocumentUpdateFailed": "Could not update document, please try again.", + "@errorMessageDocumentUpdateFailed": {}, + "errorMessageDocumentUploadFailed": "Could not upload document, please try again.", + "@errorMessageDocumentUploadFailed": {}, + "errorMessageInvalidClientCertificateConfiguration": "Invalid certificate or missing passphrase, please try again", + "@errorMessageInvalidClientCertificateConfiguration": {}, + "errorMessageLoadSavedViewsError": "Could not load views.", + "@errorMessageLoadSavedViewsError": {}, + "errorMessageMissingClientCertificate": "A client certificate was expected but not sent. Please provide a valid client certificate.", + "@errorMessageMissingClientCertificate": {}, + "errorMessageNotAuthenticated": "User is not authenticated.", + "@errorMessageNotAuthenticated": {}, + "errorMessageRequestTimedOut": "The request to the server timed out.", + "@errorMessageRequestTimedOut": {}, + "errorMessageScanRemoveFailed": "An error occurred removing the scans.", + "@errorMessageScanRemoveFailed": {}, + "errorMessageServerUnreachable": "Could not reach your Paperless server, is it up and running?", + "@errorMessageServerUnreachable": {}, + "errorMessageSimilarQueryError": "Could not load similar documents.", + "@errorMessageSimilarQueryError": {}, + "errorMessageStoragePathCreateFailed": "Could not create storage path, please try again.", + "@errorMessageStoragePathCreateFailed": {}, + "errorMessageStoragePathLoadFailed": "Could not load storage paths.", + "@errorMessageStoragePathLoadFailed": {}, + "errorMessageSuggestionsQueryError": "Could not load suggestions.", + "@errorMessageSuggestionsQueryError": {}, + "errorMessageTagCreateFailed": "Could not create tag, please try again.", + "@errorMessageTagCreateFailed": {}, + "errorMessageTagLoadFailed": "Could not load tags.", + "@errorMessageTagLoadFailed": {}, + "errorMessageUnknonwnError": "An unknown error occurred.", + "@errorMessageUnknonwnError": {}, + "errorMessageUnsupportedFileFormat": "This file format is not supported.", + "@errorMessageUnsupportedFileFormat": {}, + "errorReportLabel": "REPORT", + "@errorReportLabel": {}, + "extendedDateRangeDialogAbsoluteLabel": "Absolute", + "@extendedDateRangeDialogAbsoluteLabel": {}, + "extendedDateRangeDialogHintText": "Hint: Apart from concrete dates, you can also specify a time range relative to the current date.", + "@extendedDateRangeDialogHintText": {}, + "extendedDateRangeDialogRelativeAmountLabel": "Amount", + "@extendedDateRangeDialogRelativeAmountLabel": {}, + "extendedDateRangeDialogRelativeLabel": "Relative", + "@extendedDateRangeDialogRelativeLabel": {}, + "extendedDateRangeDialogRelativeLastLabel": "Last", + "@extendedDateRangeDialogRelativeLastLabel": {}, + "extendedDateRangeDialogRelativeTimeUnitLabel": "Time unit", + "@extendedDateRangeDialogRelativeTimeUnitLabel": {}, + "extendedDateRangeDialogTitle": "Wybierz zakres dat", + "@extendedDateRangeDialogTitle": {}, + "extendedDateRangePickerAfterLabel": "Po", + "@extendedDateRangePickerAfterLabel": {}, + "extendedDateRangePickerBeforeLabel": "Przed", + "@extendedDateRangePickerBeforeLabel": {}, + "extendedDateRangePickerDayText": "{count, plural, zero{days} one{day} other{days}}", + "@extendedDateRangePickerDayText": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerLastDaysLabel": "{count, plural, zero{} one{Yesterday} other{Last {count} days}}", + "@extendedDateRangePickerLastDaysLabel": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerLastMonthsLabel": "{count, plural, zero{} one{Last month} other{Last {count} months}}", + "@extendedDateRangePickerLastMonthsLabel": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerLastText": "Last", + "@extendedDateRangePickerLastText": {}, + "extendedDateRangePickerLastWeeksLabel": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}", + "@extendedDateRangePickerLastWeeksLabel": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerLastYearsLabel": "{count, plural, zero{} one{Last year} other{Last {count} years}}", + "@extendedDateRangePickerLastYearsLabel": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerMonthText": "{count, plural, zero{} one{month} other{months}}", + "@extendedDateRangePickerMonthText": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerWeekText": "{count, plural, zero{} one{week} other{weeks}}", + "@extendedDateRangePickerWeekText": { + "placeholders": { + "count": {} + } + }, + "extendedDateRangePickerYearText": "{count, plural, zero{} one{year} other{years}}", + "@extendedDateRangePickerYearText": { + "placeholders": { + "count": {} + } + }, + "genericAcknowledgeLabel": "Got it!", + "@genericAcknowledgeLabel": {}, + "genericActionCancelLabel": "Cancel", + "@genericActionCancelLabel": {}, + "genericActionCloseLabel": "Close", + "@genericActionCloseLabel": {}, + "genericActionCreateLabel": "Create", + "@genericActionCreateLabel": {}, + "genericActionDeleteLabel": "Delete", + "@genericActionDeleteLabel": {}, + "genericActionEditLabel": "Edit", + "@genericActionEditLabel": {}, + "genericActionOkLabel": "Ok", + "@genericActionOkLabel": {}, + "genericActionSaveLabel": "Save", + "@genericActionSaveLabel": {}, + "genericActionSelectText": "Select", + "@genericActionSelectText": {}, + "genericActionUpdateLabel": "Zapisz zmiany", + "@genericActionUpdateLabel": {}, + "genericActionUploadLabel": "Upload", + "@genericActionUploadLabel": {}, + "genericMessageOfflineText": "Jesteście w trybie offline.", + "@genericMessageOfflineText": {}, + "inboxActionAssignAsn": "Assign ASN", + "@inboxActionAssignAsn": {}, + "inboxActionDeleteDocument": "Delete document", + "@inboxActionDeleteDocument": {}, + "inboxPageAssignAsnLabel": "Assign ASN", + "@inboxPageAssignAsnLabel": {}, + "inboxPageDocumentRemovedMessageText": "Dokument usunięty ze skrzynki odbiorczej", + "@inboxPageDocumentRemovedMessageText": {}, + "inboxPageMarkAllAsSeenConfirmationDialogText": "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?", + "@inboxPageMarkAllAsSeenConfirmationDialogText": {}, + "inboxPageMarkAllAsSeenConfirmationDialogTitleText": "Mark all as seen?", + "@inboxPageMarkAllAsSeenConfirmationDialogTitleText": {}, + "inboxPageMarkAllAsSeenLabel": "All seen", + "@inboxPageMarkAllAsSeenLabel": {}, + "inboxPageMarkAsSeenText": "Mark as seen", + "@inboxPageMarkAsSeenText": {}, + "inboxPageNoNewDocumentsRefreshLabel": "Odświerz", + "@inboxPageNoNewDocumentsRefreshLabel": {}, + "inboxPageNoNewDocumentsText": "You do not have unseen documents.", + "@inboxPageNoNewDocumentsText": {}, + "inboxPageQuickActionsLabel": "Quick Action", + "@inboxPageQuickActionsLabel": {}, + "inboxPageSuggestionSuccessfullyAppliedMessage": "Suggestion successfully applied.", + "@inboxPageSuggestionSuccessfullyAppliedMessage": {}, + "inboxPageTodayText": "Dzisiaj", + "@inboxPageTodayText": {}, + "inboxPageUndoRemoveText": "Cofnij", + "@inboxPageUndoRemoveText": {}, + "inboxPageUnseenText": "unseen", + "@inboxPageUnseenText": {}, + "inboxPageUsageHintText": "Hint: Swipe left to mark a document as seen and remove all inbox tags from the document.", + "@inboxPageUsageHintText": {}, + "inboxPageYesterdayText": "Wczoraj", + "@inboxPageYesterdayText": {}, + "labelAnyAssignedText": "Any assigned", + "@labelAnyAssignedText": {}, + "labelFormFieldNoItemsFoundText": "No items found!", + "@labelFormFieldNoItemsFoundText": {}, + "labelIsInsensivitePropertyLabel": "Case Irrelevant", + "@labelIsInsensivitePropertyLabel": {}, + "labelMatchingAlgorithmPropertyLabel": "Matching Algorithm", + "@labelMatchingAlgorithmPropertyLabel": {}, + "labelMatchPropertyLabel": "Match", + "@labelMatchPropertyLabel": {}, + "labelNamePropertyLabel": "Nazwa", + "@labelNamePropertyLabel": {}, + "labelNotAssignedText": "Not assigned", + "@labelNotAssignedText": {}, + "labelsPageCorrespondentEmptyStateAddNewLabel": "Add new correspondent", + "@labelsPageCorrespondentEmptyStateAddNewLabel": {}, + "labelsPageCorrespondentEmptyStateDescriptionText": "You don't seem to have any correspondents set up.", + "@labelsPageCorrespondentEmptyStateDescriptionText": {}, + "labelsPageCorrespondentsTitleText": "Correspondents", + "@labelsPageCorrespondentsTitleText": {}, + "labelsPageDocumentTypeEmptyStateAddNewLabel": "Dodaj nowy rodzaj dokumentu", + "@labelsPageDocumentTypeEmptyStateAddNewLabel": {}, + "labelsPageDocumentTypeEmptyStateDescriptionText": "You don't seem to have any document types set up.", + "@labelsPageDocumentTypeEmptyStateDescriptionText": {}, + "labelsPageDocumentTypesTitleText": "Rodzaje dokumentów", + "@labelsPageDocumentTypesTitleText": {}, + "labelsPageStoragePathEmptyStateAddNewLabel": "Add new storage path", + "@labelsPageStoragePathEmptyStateAddNewLabel": {}, + "labelsPageStoragePathEmptyStateDescriptionText": "You don't seem to have any storage paths set up.", + "@labelsPageStoragePathEmptyStateDescriptionText": {}, + "labelsPageStoragePathTitleText": "Storage Paths", + "@labelsPageStoragePathTitleText": {}, + "labelsPageTagsEmptyStateAddNewLabel": "Dodaj nowy tag", + "@labelsPageTagsEmptyStateAddNewLabel": {}, + "labelsPageTagsEmptyStateDescriptionText": "You don't seem to have any tags set up.", + "@labelsPageTagsEmptyStateDescriptionText": {}, + "labelsPageTagsTitleText": "Tagi", + "@labelsPageTagsTitleText": {}, + "linkedDocumentsPageTitle": "Linked Documents", + "@linkedDocumentsPageTitle": {}, + "loginPageAdvancedLabel": "Advanced Settings", + "@loginPageAdvancedLabel": {}, + "loginPageClientCertificatePassphraseLabel": "Passphrase", + "@loginPageClientCertificatePassphraseLabel": {}, + "loginPageClientCertificateSettingDescriptionText": "Configure Mutual TLS Authentication", + "@loginPageClientCertificateSettingDescriptionText": {}, + "loginPageClientCertificateSettingInvalidFileFormatValidationText": "Invalid certificate format, only .pfx is allowed", + "@loginPageClientCertificateSettingInvalidFileFormatValidationText": {}, + "loginPageClientCertificateSettingLabel": "Client Certificate", + "@loginPageClientCertificateSettingLabel": {}, + "loginPageClientCertificateSettingSelectFileText": "Select file...", + "@loginPageClientCertificateSettingSelectFileText": {}, + "loginPageContinueLabel": "Kontynuuj", + "@loginPageContinueLabel": {}, + "loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": "Incorrect or missing certificate passphrase.", + "@loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": {}, + "loginPageLoginButtonLabel": "Polącz", + "@loginPageLoginButtonLabel": {}, + "loginPagePasswordFieldLabel": "Hasło", + "@loginPagePasswordFieldLabel": {}, + "loginPagePasswordValidatorMessageText": "Hasło nie może być puste.", + "@loginPagePasswordValidatorMessageText": {}, + "loginPageReachabilityConnectionTimeoutText": "Connection timed out.", + "@loginPageReachabilityConnectionTimeoutText": {}, + "loginPageReachabilityInvalidClientCertificateConfigurationText": "Incorrect or missing client certificate passphrase.", + "@loginPageReachabilityInvalidClientCertificateConfigurationText": {}, + "loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.", + "@loginPageReachabilityMissingClientCertificateText": {}, + "loginPageReachabilityNotReachableText": "Could not establish a connection to the server.", + "@loginPageReachabilityNotReachableText": {}, + "loginPageReachabilitySuccessText": "Connection successfully established.", + "@loginPageReachabilitySuccessText": {}, + "loginPageReachabilityUnresolvedHostText": "Host could not be resolved. Please check the server address and your internet connection. ", + "@loginPageReachabilityUnresolvedHostText": {}, + "loginPageServerUrlFieldLabel": "Adres serwera", + "@loginPageServerUrlFieldLabel": {}, + "loginPageServerUrlValidatorMessageInvalidAddressText": "Invalid address.", + "@loginPageServerUrlValidatorMessageInvalidAddressText": {}, + "loginPageServerUrlValidatorMessageMissingSchemeText": "Server address must include a scheme.", + "@loginPageServerUrlValidatorMessageMissingSchemeText": {}, + "loginPageServerUrlValidatorMessageRequiredText": "Server address must not be empty.", + "@loginPageServerUrlValidatorMessageRequiredText": {}, + "loginPageSignInButtonLabel": "Sign In", + "@loginPageSignInButtonLabel": {}, + "loginPageSignInTitle": "Sign In", + "@loginPageSignInTitle": {}, + "loginPageSignInToPrefixText": "Sign in to {serverAddress}", + "@loginPageSignInToPrefixText": { + "placeholders": { + "serverAddress": {} + } + }, + "loginPageTitle": "Connect to Paperless", + "@loginPageTitle": {}, + "loginPageUsernameLabel": "Username", + "@loginPageUsernameLabel": {}, + "loginPageUsernameValidatorMessageText": "Username must not be empty.", + "@loginPageUsernameValidatorMessageText": {}, + "matchingAlgorithmAllDescription": "Document contains all of these words", + "@matchingAlgorithmAllDescription": {}, + "matchingAlgorithmAllName": "All", + "@matchingAlgorithmAllName": {}, + "matchingAlgorithmAnyDescription": "Document contains any of these words", + "@matchingAlgorithmAnyDescription": {}, + "matchingAlgorithmAnyName": "Any", + "@matchingAlgorithmAnyName": {}, + "matchingAlgorithmAutoDescription": "Learn matching automatically", + "@matchingAlgorithmAutoDescription": {}, + "matchingAlgorithmAutoName": "Auto", + "@matchingAlgorithmAutoName": {}, + "matchingAlgorithmExactDescription": "Document contains this string", + "@matchingAlgorithmExactDescription": {}, + "matchingAlgorithmExactName": "Exact", + "@matchingAlgorithmExactName": {}, + "matchingAlgorithmFuzzyDescription": "Document contains a word similar to this word", + "@matchingAlgorithmFuzzyDescription": {}, + "matchingAlgorithmFuzzyName": "Fuzzy", + "@matchingAlgorithmFuzzyName": {}, + "matchingAlgorithmRegexDescription": "Document matches this regular expression", + "@matchingAlgorithmRegexDescription": {}, + "matchingAlgorithmRegexName": "Regular Expression", + "@matchingAlgorithmRegexName": {}, + "offlineWidgetText": "Nie można było nawiązać połączenia internetowego.", + "@offlineWidgetText": {}, + "onboardingDoneButtonLabel": "Done", + "@onboardingDoneButtonLabel": {}, + "onboardingNextButtonLabel": "Następne", + "@onboardingNextButtonLabel": {}, + "receiveSharedFilePermissionDeniedMessage": "Could not access the received file. Please try to open the app before sharing.", + "@receiveSharedFilePermissionDeniedMessage": {}, + "referencedDocumentsReadOnlyHintText": "This is a read-only view! You cannot edit or remove documents. A maximum of 100 referenced documents will be loaded.", + "@referencedDocumentsReadOnlyHintText": {}, + "savedViewCreateNewLabel": "New View", + "@savedViewCreateNewLabel": {}, + "savedViewCreateTooltipText": "Creates a new view based on the current filter criteria.", + "@savedViewCreateTooltipText": {}, + "savedViewNameLabel": "Nazwa", + "@savedViewNameLabel": {}, + "savedViewsEmptyStateText": "Create views to quickly filter your documents.", + "@savedViewsEmptyStateText": {}, + "savedViewsFiltersSetCount": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "@savedViewsFiltersSetCount": { + "placeholders": { + "count": {} + } + }, + "savedViewShowInSidebarLabel": "Show in sidebar", + "@savedViewShowInSidebarLabel": {}, + "savedViewShowOnDashboardLabel": "Show on dashboard", + "@savedViewShowOnDashboardLabel": {}, + "savedViewsLabel": "Views", + "@savedViewsLabel": {}, + "scannerPageClearAllLabel": "Clear all", + "@scannerPageClearAllLabel": {}, + "scannerPageImagePreviewTitle": "Skanuj", + "@scannerPageImagePreviewTitle": {}, + "scannerPagePreviewLabel": "Preview", + "@scannerPagePreviewLabel": {}, + "scannerPageUploadLabel": "Upload", + "@scannerPageUploadLabel": {}, + "serverInformationPaperlessVersionText": "Wersja serwera Paperless", + "@serverInformationPaperlessVersionText": {}, + "settingsPageAppearanceSettingDarkThemeLabel": "Motyw ciemny", + "@settingsPageAppearanceSettingDarkThemeLabel": {}, + "settingsPageAppearanceSettingLightThemeLabel": "Motyw jasny", + "@settingsPageAppearanceSettingLightThemeLabel": {}, + "settingsPageAppearanceSettingSystemThemeLabel": "Użyj motywu systemu", + "@settingsPageAppearanceSettingSystemThemeLabel": {}, + "settingsPageAppearanceSettingTitle": "Wygląd", + "@settingsPageAppearanceSettingTitle": {}, + "settingsPageApplicationSettingsDescriptionText": "Język i wygląd", + "@settingsPageApplicationSettingsDescriptionText": {}, + "settingsPageApplicationSettingsLabel": "Aplikacja", + "@settingsPageApplicationSettingsLabel": {}, + "settingsPageColorSchemeSettingDialogDescription": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "@settingsPageColorSchemeSettingDialogDescription": {}, + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "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.", + "@settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": {}, + "settingsPageColorSchemeSettingLabel": "Kolory", + "@settingsPageColorSchemeSettingLabel": {}, + "settingsPageLanguageSettingLabel": "Język", + "@settingsPageLanguageSettingLabel": {}, + "settingsPageSecuritySettingsDescriptionText": "Uwierzytelnianie biometryczne", + "@settingsPageSecuritySettingsDescriptionText": {}, + "settingsPageSecuritySettingsLabel": "Zabezpieczenia", + "@settingsPageSecuritySettingsLabel": {}, + "settingsPageStorageSettingsDescriptionText": "Manage files and storage space", + "@settingsPageStorageSettingsDescriptionText": {}, + "settingsPageStorageSettingsLabel": "Storage", + "@settingsPageStorageSettingsLabel": {}, + "settingsThemeModeDarkLabel": "Ciemny", + "@settingsThemeModeDarkLabel": {}, + "settingsThemeModeLightLabel": "Jasny", + "@settingsThemeModeLightLabel": {}, + "settingsThemeModeSystemLabel": "System", + "@settingsThemeModeSystemLabel": {}, + "sortDocumentAscending": "Ascending", + "@sortDocumentAscending": {}, + "sortDocumentDescending": "Descending", + "@sortDocumentDescending": {}, + "storagePathParameterDayLabel": "dzień", + "@storagePathParameterDayLabel": {}, + "storagePathParameterMonthLabel": "miesiąc", + "@storagePathParameterMonthLabel": {}, + "storagePathParameterYearLabel": "rok", + "@storagePathParameterYearLabel": {}, + "tagColorPropertyLabel": "Kolor", + "@tagColorPropertyLabel": {}, + "tagFormFieldSearchHintText": "Filter tags...", + "@tagFormFieldSearchHintText": {}, + "tagInboxTagPropertyLabel": "Tag skrzynki odbiorczej", + "@tagInboxTagPropertyLabel": {}, + "uploadPageAutomaticallInferredFieldsHintText": "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.", + "@uploadPageAutomaticallInferredFieldsHintText": {}, + "verifyIdentityPageDescriptionText": "Use the configured biometric factor to authenticate and unlock your documents.", + "@verifyIdentityPageDescriptionText": {}, + "verifyIdentityPageLogoutButtonLabel": "Disconnect", + "@verifyIdentityPageLogoutButtonLabel": {}, + "verifyIdentityPageTitle": "Verify your identity", + "@verifyIdentityPageTitle": {}, + "verifyIdentityPageVerifyIdentityButtonLabel": "Verify Identity", + "@verifyIdentityPageVerifyIdentityButtonLabel": {} +} \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 17651e8..25b7b62 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -6,6 +6,10 @@ "name": {} } }, + "accountSettingsAddAnotherAccount": "Add another account", + "@accountSettingsAddAnotherAccount": {}, + "accountSettingsTitle": "Account", + "@accountSettingsTitle": {}, "addCorrespondentPageTitle": "Yeni ek yazar", "@addCorrespondentPageTitle": {}, "addDocumentTypePageTitle": "Yeni Belge Türü", @@ -44,6 +48,10 @@ "@bottomNavLabelsPageLabel": {}, "bottomNavScannerPageLabel": "Tarayıcı", "@bottomNavScannerPageLabel": {}, + "colorSchemeOptionClassic": "Classic", + "@colorSchemeOptionClassic": {}, + "colorSchemeOptionDynamic": "Dynamic", + "@colorSchemeOptionDynamic": {}, "correspondentFormFieldSearchHintText": "Yazmaya başlayın...", "@correspondentFormFieldSearchHintText": {}, "deleteViewDialogContentText": "Bu görünümü gerçekten silmek istiyor musunuz?", @@ -90,6 +98,8 @@ "@documentDetailsPageTabMetaDataLabel": {}, "documentDetailsPageTabOverviewLabel": "Genel bakış", "@documentDetailsPageTabOverviewLabel": {}, + "documentDetailsPageTabSimilarDocumentsLabel": "Similar Documents", + "@documentDetailsPageTabSimilarDocumentsLabel": {}, "documentDocumentTypePropertyLabel": "Döküman tipi", "@documentDocumentTypePropertyLabel": {}, "documentDownloadSuccessMessage": "Döküman başarıyla indirildi.", @@ -142,6 +152,16 @@ "@documentScannerPageUploadButtonTooltip": {}, "documentScannerPageUploadFromThisDeviceButtonLabel": "Bu cihazdan bir döküman yükleyin", "@documentScannerPageUploadFromThisDeviceButtonLabel": {}, + "documentSearchHistory": "History", + "@documentSearchHistory": {}, + "documentSearchNoMatchesFound": "No matches found.", + "@documentSearchNoMatchesFound": {}, + "documentSearchPageRemoveFromHistory": "Remove from search history?", + "@documentSearchPageRemoveFromHistory": {}, + "documentSearchResults": "Results", + "@documentSearchResults": {}, + "documentSearchSearchDocuments": "Search documents", + "@documentSearchSearchDocuments": {}, "documentsEmptyStateResetFilterLabel": "Filtreyi sıfırla", "@documentsEmptyStateResetFilterLabel": {}, "documentsFilterPageAdvancedLabel": "Gelişmiş", @@ -358,6 +378,8 @@ "@genericAcknowledgeLabel": {}, "genericActionCancelLabel": "İptal", "@genericActionCancelLabel": {}, + "genericActionCloseLabel": "Close", + "@genericActionCloseLabel": {}, "genericActionCreateLabel": "Yarat", "@genericActionCreateLabel": {}, "genericActionDeleteLabel": "Sil", @@ -376,6 +398,10 @@ "@genericActionUploadLabel": {}, "genericMessageOfflineText": "Çevrimdışısınız.", "@genericMessageOfflineText": {}, + "inboxActionAssignAsn": "Assign ASN", + "@inboxActionAssignAsn": {}, + "inboxActionDeleteDocument": "Delete document", + "@inboxActionDeleteDocument": {}, "inboxPageAssignAsnLabel": "ASN ata", "@inboxPageAssignAsnLabel": {}, "inboxPageDocumentRemovedMessageText": "Döküman gelen kutusundan kaldırıldı.", @@ -546,14 +572,26 @@ "@savedViewNameLabel": {}, "savedViewsEmptyStateText": "Belgelerinizi hızla filtrelemek için görünümler oluşturun.", "@savedViewsEmptyStateText": {}, + "savedViewsFiltersSetCount": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "@savedViewsFiltersSetCount": { + "placeholders": { + "count": {} + } + }, "savedViewShowInSidebarLabel": "Kenar çubuğunda göster", "@savedViewShowInSidebarLabel": {}, "savedViewShowOnDashboardLabel": "Kontrol panelinde göster", "@savedViewShowOnDashboardLabel": {}, "savedViewsLabel": "Kayıtlı Görünümler", "@savedViewsLabel": {}, + "scannerPageClearAllLabel": "Clear all", + "@scannerPageClearAllLabel": {}, "scannerPageImagePreviewTitle": "Tara", "@scannerPageImagePreviewTitle": {}, + "scannerPagePreviewLabel": "Preview", + "@scannerPagePreviewLabel": {}, + "scannerPageUploadLabel": "Upload", + "@scannerPageUploadLabel": {}, "serverInformationPaperlessVersionText": "Paperless sunucu versiyonu", "@serverInformationPaperlessVersionText": {}, "settingsPageAppearanceSettingDarkThemeLabel": "Koyu Tema", @@ -568,6 +606,12 @@ "@settingsPageApplicationSettingsDescriptionText": {}, "settingsPageApplicationSettingsLabel": "Uygulama", "@settingsPageApplicationSettingsLabel": {}, + "settingsPageColorSchemeSettingDialogDescription": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "@settingsPageColorSchemeSettingDialogDescription": {}, + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "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.", + "@settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": {}, + "settingsPageColorSchemeSettingLabel": "Colors", + "@settingsPageColorSchemeSettingLabel": {}, "settingsPageLanguageSettingLabel": "Dil", "@settingsPageLanguageSettingLabel": {}, "settingsPageSecuritySettingsDescriptionText": "Biyometrik kimlik doğrulama", @@ -584,6 +628,10 @@ "@settingsThemeModeLightLabel": {}, "settingsThemeModeSystemLabel": "Sistem", "@settingsThemeModeSystemLabel": {}, + "sortDocumentAscending": "Ascending", + "@sortDocumentAscending": {}, + "sortDocumentDescending": "Descending", + "@sortDocumentDescending": {}, "storagePathParameterDayLabel": "gün", "@storagePathParameterDayLabel": {}, "storagePathParameterMonthLabel": "ay", diff --git a/lib/main.dart b/lib/main.dart index 99f0594..e0cf35c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,11 @@ -import 'dart:developer'; +import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm; +import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; @@ -12,12 +13,14 @@ 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:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/bloc_changes_observer.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/saved_view_repository_impl.dart'; @@ -25,15 +28,9 @@ import 'package:paperless_mobile/core/repository/impl/storage_path_repository_im import 'package:paperless_mobile/core/repository/impl/tag_repository_impl.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/state/impl/correspondent_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; -import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.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/dio_file_service.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/home/view/home_page.dart'; import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; @@ -43,29 +40,37 @@ import 'package:paperless_mobile/features/login/services/authentication_service. 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/bloc/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.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.dart'; +import 'package:paperless_mobile/routes/document_details_route.dart'; +import 'package:paperless_mobile/theme.dart'; +import 'package:paperless_mobile/constants.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:dynamic_color/dynamic_color.dart'; void main() async { Bloc.observer = BlocChangesObserver(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); await findSystemLocale(); + packageInfo = await PackageInfo.fromPlatform(); + if (Platform.isAndroid) { + androidInfo = await DeviceInfoPlugin().androidInfo; + } + if (Platform.isIOS) { + iosInfo = await DeviceInfoPlugin().iosInfo; + } // Initialize External dependencies final connectivity = Connectivity(); - final encryptedSharedPreferences = EncryptedSharedPreferences(); final localAuthentication = LocalAuthentication(); // Initialize other utility classes final connectivityStatusService = ConnectivityStatusServiceImpl(connectivity); - final localVault = LocalVaultImpl(encryptedSharedPreferences); - final localAuthService = - LocalAuthenticationService(localVault, localAuthentication); + final localAuthService = LocalAuthenticationService(localAuthentication); final hiveDir = await getApplicationDocumentsDirectory(); HydratedBloc.storage = await HydratedStorage.build( @@ -136,6 +141,10 @@ void main() async { appSettingsCubit.stream.listen((event) => languageHeaderInterceptor .preferredLocaleSubtag = event.preferredLocaleSubtag); + // Temporary Fix: Can be removed if the flutter engine implements the fix itself + // Activate the highest availabe refresh rate on the device + await FlutterDisplayMode.setHighRefreshRate(); + runApp( MultiProvider( providers: [ @@ -153,30 +162,26 @@ void main() async { ), ), ), - Provider.value(value: localVault), Provider.value( value: connectivityStatusService, ), Provider.value( value: localNotificationService, ), + Provider.value(value: DocumentChangedNotifier()), ], child: MultiRepositoryProvider( providers: [ - RepositoryProvider>.value( + RepositoryProvider>.value( value: tagRepository, ), - RepositoryProvider< - LabelRepository>.value( + RepositoryProvider>.value( value: correspondentRepository, ), - RepositoryProvider< - LabelRepository>.value( + RepositoryProvider>.value( value: documentTypeRepository, ), - RepositoryProvider< - LabelRepository>.value( + RepositoryProvider>.value( value: storagePathRepository, ), RepositoryProvider.value( @@ -208,22 +213,6 @@ class PaperlessMobileEntrypoint extends StatefulWidget { } class _PaperlessMobileEntrypointState extends State { - final _lightTheme = ThemeData.from( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.lightGreen, - brightness: Brightness.light, - ), - useMaterial3: true, - ); - - final _darkTheme = ThemeData.from( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.lightGreen, - brightness: Brightness.dark, - ), - useMaterial3: true, - ); - @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -236,52 +225,40 @@ class _PaperlessMobileEntrypointState extends State { ], child: BlocBuilder( builder: (context, settings) { - return MaterialApp( - debugShowCheckedModeBanner: true, - title: "Paperless Mobile", - theme: _lightTheme.copyWith( - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return MaterialApp( + debugShowCheckedModeBanner: true, + title: "Paperless Mobile", + theme: buildTheme( + brightness: Brightness.light, + dynamicScheme: lightDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 16.0, + darkTheme: buildTheme( + brightness: Brightness.dark, + dynamicScheme: darkDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, ), - ), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - listTileTheme: const ListTileThemeData( - tileColor: Colors.transparent, - ), - ), - darkTheme: _darkTheme.copyWith( - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + themeMode: settings.preferredThemeMode, + supportedLocales: S.delegate.supportedLocales, + locale: Locale.fromSubtags( + languageCode: settings.preferredLocaleSubtag, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 16.0, - ), - ), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - listTileTheme: const ListTileThemeData( - tileColor: Colors.transparent, - ), - ), - themeMode: settings.preferredThemeMode, - supportedLocales: S.delegate.supportedLocales, - locale: Locale.fromSubtags( - languageCode: settings.preferredLocaleSubtag, - ), - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - FormBuilderLocalizations.delegate, - ], - home: const AuthenticationWrapper(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + FormBuilderLocalizations.delegate, + ], + routes: { + DocumentDetailsRoute.routeName: (context) => + const DocumentDetailsRoute(), + }, + home: const AuthenticationWrapper(), + ); + }, ); }, ), diff --git a/lib/routes/document_details_route.dart b/lib/routes/document_details_route.dart new file mode 100644 index 0000000..8db47c3 --- /dev/null +++ b/lib/routes/document_details_route.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; +import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; + +class DocumentDetailsRoute extends StatelessWidget { + static const String routeName = "/documentDetails"; + const DocumentDetailsRoute({super.key}); + + @override + Widget build(BuildContext context) { + final args = ModalRoute.of(context)!.settings.arguments + as DocumentDetailsRouteArguments; + + return BlocProvider( + create: (context) => DocumentDetailsCubit( + context.read(), + context.read(), + initialDocument: args.document, + ), + child: LabelRepositoriesProvider( + child: DocumentDetailsPage( + allowEdit: args.allowEdit, + isLabelClickable: args.isLabelClickable, + titleAndContentQueryString: args.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/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..adee91d --- /dev/null +++ b/lib/theme.dart @@ -0,0 +1,55 @@ +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; + +const _classicThemeColorSeed = Colors.lightGreen; + +const _defaultListTileTheme = ListTileThemeData( + tileColor: Colors.transparent, +); + +final _defaultInputDecorationTheme = InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), +); + +ThemeData buildTheme({ + required Brightness brightness, + required ColorSchemeOption preferredColorScheme, + ColorScheme? dynamicScheme, +}) { + final classicScheme = ColorScheme.fromSeed( + seedColor: _classicThemeColorSeed, + brightness: brightness, + ).harmonized(); + late ColorScheme colorScheme; + switch (preferredColorScheme) { + case ColorSchemeOption.classic: + colorScheme = classicScheme; + break; + case ColorSchemeOption.dynamic: + colorScheme = dynamicScheme ?? classicScheme; + break; + } + return ThemeData.from( + colorScheme: colorScheme.harmonized(), + useMaterial3: true, + ).copyWith( + inputDecorationTheme: _defaultInputDecorationTheme, + listTileTheme: _defaultListTileTheme, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + appBarTheme: AppBarTheme( + scrolledUnderElevation: 0, + ), + chipTheme: ChipThemeData( + backgroundColor: colorScheme.surfaceVariant, + checkmarkColor: colorScheme.onSurfaceVariant, + deleteIconColor: colorScheme.onSurfaceVariant, + ), + ); +} diff --git a/packages/paperless_api/lib/src/converters/converters.dart b/packages/paperless_api/lib/src/converters/converters.dart index 10a8c8e..8a96d0c 100644 --- a/packages/paperless_api/lib/src/converters/converters.dart +++ b/packages/paperless_api/lib/src/converters/converters.dart @@ -1,3 +1,2 @@ export 'document_model_json_converter.dart'; -export 'similar_document_model_json_converter.dart'; export 'date_range_query_json_converter.dart'; diff --git a/packages/paperless_api/lib/src/converters/hex_color_json_converter.dart b/packages/paperless_api/lib/src/converters/hex_color_json_converter.dart new file mode 100644 index 0000000..3a73baf --- /dev/null +++ b/packages/paperless_api/lib/src/converters/hex_color_json_converter.dart @@ -0,0 +1,31 @@ +import 'dart:ui'; + +import 'package:json_annotation/json_annotation.dart'; + +class HexColorJsonConverter implements JsonConverter { + const HexColorJsonConverter(); + @override + Color? fromJson(dynamic json) { + if (json is Color) { + return json; + } + if (json is String) { + final decoded = int.tryParse(json.replaceAll("#", "ff"), radix: 16); + if (decoded == null) { + return null; + } + return Color(decoded); + } + return null; + } + + @override + String? toJson(Color? color) { + if (color == null) { + return null; + } + String val = + '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}'; + return val; + } +} diff --git a/packages/paperless_api/lib/src/converters/similar_document_model_json_converter.dart b/packages/paperless_api/lib/src/converters/similar_document_model_json_converter.dart deleted file mode 100644 index 2b34c84..0000000 --- a/packages/paperless_api/lib/src/converters/similar_document_model_json_converter.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/paperless_api.dart'; - -class SimilarDocumentModelJsonConverter - extends JsonConverter> { - @override - SimilarDocumentModel fromJson(Map json) { - return SimilarDocumentModel.fromJson(json); - } - - @override - Map toJson(SimilarDocumentModel object) { - return object.toJson(); - } -} diff --git a/packages/paperless_api/lib/src/models/document_filter.dart b/packages/paperless_api/lib/src/models/document_filter.dart index a2b5363..8ac6d99 100644 --- a/packages/paperless_api/lib/src/models/document_filter.dart +++ b/packages/paperless_api/lib/src/models/document_filter.dart @@ -26,13 +26,16 @@ class DocumentFilter extends Equatable { final IdQueryParameter storagePath; final IdQueryParameter asnQuery; final TagsQuery tags; - final SortField sortField; + final SortField? sortField; final SortOrder sortOrder; final DateRangeQuery created; final DateRangeQuery added; final DateRangeQuery modified; final TextQuery query; + /// Query documents similar to the document with this id. + final int? moreLike; + const DocumentFilter({ this.documentType = const IdQueryParameter.unset(), this.correspondent = const IdQueryParameter.unset(), @@ -47,6 +50,7 @@ class DocumentFilter extends Equatable { this.added = const UnsetDateRangeQuery(), this.created = const UnsetDateRangeQuery(), this.modified = const UnsetDateRangeQuery(), + this.moreLike, }); bool get forceExtendedQuery { @@ -59,7 +63,6 @@ class DocumentFilter extends Equatable { List> params = [ MapEntry('page', '$page'), MapEntry('page_size', '$pageSize'), - MapEntry('ordering', '${sortOrder.queryString}${sortField.queryString}'), ...documentType.toQueryParameter('document_type').entries, ...correspondent.toQueryParameter('correspondent').entries, ...storagePath.toQueryParameter('storage_path').entries, @@ -70,6 +73,18 @@ class DocumentFilter extends Equatable { ...modified.toQueryParameter(DateRangeQueryField.modified).entries, ...query.toQueryParameter().entries, ]; + if (sortField != null) { + params.add( + MapEntry( + 'ordering', + '${sortOrder.queryString}${sortField!.queryString}', + ), + ); + } + + if (moreLike != null) { + params.add(MapEntry('more_like_id', moreLike.toString())); + } // Reverse ordering can also be encoded using &reverse=1 // Merge query params final queryParams = groupBy(params, (e) => e.key).map( @@ -100,7 +115,7 @@ class DocumentFilter extends Equatable { DateRangeQuery? created, DateRangeQuery? modified, TextQuery? query, - int? selectedViewId, + int? Function()? moreLike, }) { final newFilter = DocumentFilter( pageSize: pageSize ?? this.pageSize, @@ -116,6 +131,7 @@ class DocumentFilter extends Equatable { added: added ?? this.added, created: created ?? this.created, modified: modified ?? this.modified, + moreLike: moreLike != null ? moreLike.call() : this.moreLike, ); if (query?.queryType != QueryType.extended && newFilter.forceExtendedQuery) { @@ -127,6 +143,24 @@ class DocumentFilter extends Equatable { return newFilter; } + /// + /// Checks whether the properties of [document] match the current filter criteria. + /// + bool matches(DocumentModel document) { + return correspondent.matches(document.correspondent) && + documentType.matches(document.documentType) && + storagePath.matches(document.storagePath) && + tags.matches(document.tags) && + created.matches(document.created) && + added.matches(document.added) && + modified.matches(document.modified) && + query.matches( + title: document.title, + content: document.content, + asn: document.archiveSerialNumber, + ); + } + int get appliedFiltersCount => [ documentType != initial.documentType, correspondent != initial.correspondent, diff --git a/packages/paperless_api/lib/src/models/document_filter.g.dart b/packages/paperless_api/lib/src/models/document_filter.g.dart index 073c517..6885d77 100644 --- a/packages/paperless_api/lib/src/models/document_filter.g.dart +++ b/packages/paperless_api/lib/src/models/document_filter.g.dart @@ -48,6 +48,7 @@ DocumentFilter _$DocumentFilterFromJson(Map json) => ? const UnsetDateRangeQuery() : const DateRangeQueryJsonConverter() .fromJson(json['modified'] as Map), + moreLike: json['moreLike'] as int?, ); Map _$DocumentFilterToJson(DocumentFilter instance) => @@ -59,19 +60,20 @@ Map _$DocumentFilterToJson(DocumentFilter instance) => 'storagePath': instance.storagePath.toJson(), 'asnQuery': instance.asnQuery.toJson(), 'tags': const TagsQueryJsonConverter().toJson(instance.tags), - 'sortField': _$SortFieldEnumMap[instance.sortField]!, + 'sortField': _$SortFieldEnumMap[instance.sortField], 'sortOrder': _$SortOrderEnumMap[instance.sortOrder]!, 'created': const DateRangeQueryJsonConverter().toJson(instance.created), 'added': const DateRangeQueryJsonConverter().toJson(instance.added), 'modified': const DateRangeQueryJsonConverter().toJson(instance.modified), 'query': instance.query.toJson(), + 'moreLike': instance.moreLike, }; const _$SortFieldEnumMap = { - SortField.archiveSerialNumber: 'archiveSerialNumber', - SortField.correspondentName: 'correspondentName', + SortField.archiveSerialNumber: 'archive_serial_number', + SortField.correspondentName: 'correspondent__name', SortField.title: 'title', - SortField.documentType: 'documentType', + SortField.documentType: 'document_type__name', SortField.created: 'created', SortField.added: 'added', SortField.modified: 'modified', diff --git a/packages/paperless_api/lib/src/models/document_model.dart b/packages/paperless_api/lib/src/models/document_model.dart index 9fad865..42e9609 100644 --- a/packages/paperless_api/lib/src/models/document_model.dart +++ b/packages/paperless_api/lib/src/models/document_model.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; 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/search_hit.dart'; part 'document_model.g.dart'; @@ -37,6 +38,12 @@ class DocumentModel extends Equatable { final String originalFileName; final String? archivedFileName; + @JsonKey( + name: '__search_hit__', + includeIfNull: false, + ) + final SearchHit? searchHit; + const DocumentModel({ required this.id, required this.title, @@ -51,6 +58,7 @@ class DocumentModel extends Equatable { required this.originalFileName, this.archivedFileName, this.storagePath, + this.searchHit, }); factory DocumentModel.fromJson(Map json) => @@ -76,9 +84,11 @@ class DocumentModel extends Equatable { id: id, title: title ?? this.title, content: content ?? this.content, - documentType: documentType?.call() ?? this.documentType, - correspondent: correspondent?.call() ?? this.correspondent, - storagePath: storagePath?.call() ?? this.storagePath, + documentType: + documentType != null ? documentType.call() : this.documentType, + correspondent: + correspondent != null ? correspondent.call() : this.correspondent, + storagePath: storagePath != null ? storagePath.call() : this.storagePath, tags: tags ?? this.tags, created: created ?? this.created, modified: modified ?? this.modified, diff --git a/packages/paperless_api/lib/src/models/document_model.g.dart b/packages/paperless_api/lib/src/models/document_model.g.dart index 90c6fa8..83df5ed 100644 --- a/packages/paperless_api/lib/src/models/document_model.g.dart +++ b/packages/paperless_api/lib/src/models/document_model.g.dart @@ -25,21 +25,34 @@ DocumentModel _$DocumentModelFromJson(Map json) => originalFileName: json['original_file_name'] as String, archivedFileName: json['archived_file_name'] as String?, storagePath: json['storage_path'] as int?, + searchHit: json['__search_hit__'] == null + ? null + : SearchHit.fromJson(json['__search_hit__'] as Map), ); -Map _$DocumentModelToJson(DocumentModel instance) => - { - 'id': instance.id, - 'title': instance.title, - 'content': instance.content, - 'tags': instance.tags.toList(), - 'document_type': instance.documentType, - 'correspondent': instance.correspondent, - 'storage_path': instance.storagePath, - 'created': const LocalDateTimeJsonConverter().toJson(instance.created), - 'modified': const LocalDateTimeJsonConverter().toJson(instance.modified), - 'added': const LocalDateTimeJsonConverter().toJson(instance.added), - 'archive_serial_number': instance.archiveSerialNumber, - 'original_file_name': instance.originalFileName, - 'archived_file_name': instance.archivedFileName, - }; +Map _$DocumentModelToJson(DocumentModel instance) { + final val = { + 'id': instance.id, + 'title': instance.title, + 'content': instance.content, + 'tags': instance.tags.toList(), + 'document_type': instance.documentType, + 'correspondent': instance.correspondent, + 'storage_path': instance.storagePath, + 'created': const LocalDateTimeJsonConverter().toJson(instance.created), + 'modified': const LocalDateTimeJsonConverter().toJson(instance.modified), + 'added': const LocalDateTimeJsonConverter().toJson(instance.added), + 'archive_serial_number': instance.archiveSerialNumber, + 'original_file_name': instance.originalFileName, + 'archived_file_name': instance.archivedFileName, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('__search_hit__', instance.searchHit); + return val; +} diff --git a/packages/paperless_api/lib/src/models/labels/correspondent_model.dart b/packages/paperless_api/lib/src/models/labels/correspondent_model.dart index e0602a1..2a7523b 100644 --- a/packages/paperless_api/lib/src/models/labels/correspondent_model.dart +++ b/packages/paperless_api/lib/src/models/labels/correspondent_model.dart @@ -11,11 +11,11 @@ class Correspondent extends Label { final DateTime? lastCorrespondence; const Correspondent({ - required super.id, + super.id, required super.name, super.slug, super.match, - required super.matchingAlgorithm, + super.matchingAlgorithm, super.isInsensitive, super.documentCount, this.lastCorrespondence, @@ -24,6 +24,7 @@ class Correspondent extends Label { factory Correspondent.fromJson(Map json) => _$CorrespondentFromJson(json); + @override Map toJson() => _$CorrespondentToJson(this); @override diff --git a/packages/paperless_api/lib/src/models/labels/correspondent_model.g.dart b/packages/paperless_api/lib/src/models/labels/correspondent_model.g.dart index 7354ce7..abd32f6 100644 --- a/packages/paperless_api/lib/src/models/labels/correspondent_model.g.dart +++ b/packages/paperless_api/lib/src/models/labels/correspondent_model.g.dart @@ -12,9 +12,10 @@ Correspondent _$CorrespondentFromJson(Map json) => name: json['name'] as String, slug: json['slug'] as String?, match: json['match'] as String?, - matchingAlgorithm: - $enumDecode(_$MatchingAlgorithmEnumMap, json['matching_algorithm']), - isInsensitive: json['is_insensitive'] as bool?, + matchingAlgorithm: $enumDecodeNullable( + _$MatchingAlgorithmEnumMap, json['matching_algorithm']) ?? + MatchingAlgorithm.defaultValue, + isInsensitive: json['is_insensitive'] as bool? ?? true, documentCount: json['document_count'] as int?, lastCorrespondence: _$JsonConverterFromJson( json['last_correspondence'], 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 index 76be83e..085f822 100644 --- a/packages/paperless_api/lib/src/models/labels/document_type_model.dart +++ b/packages/paperless_api/lib/src/models/labels/document_type_model.dart @@ -6,11 +6,11 @@ part 'document_type_model.g.dart'; @JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) class DocumentType extends Label { const DocumentType({ - required super.id, + super.id, required super.name, super.slug, super.match, - required super.matchingAlgorithm, + super.matchingAlgorithm, super.isInsensitive, super.documentCount, }); diff --git a/packages/paperless_api/lib/src/models/labels/document_type_model.g.dart b/packages/paperless_api/lib/src/models/labels/document_type_model.g.dart index 93567b1..be8b0eb 100644 --- a/packages/paperless_api/lib/src/models/labels/document_type_model.g.dart +++ b/packages/paperless_api/lib/src/models/labels/document_type_model.g.dart @@ -11,9 +11,10 @@ DocumentType _$DocumentTypeFromJson(Map json) => DocumentType( name: json['name'] as String, slug: json['slug'] as String?, match: json['match'] as String?, - matchingAlgorithm: - $enumDecode(_$MatchingAlgorithmEnumMap, json['matching_algorithm']), - isInsensitive: json['is_insensitive'] as bool?, + matchingAlgorithm: $enumDecodeNullable( + _$MatchingAlgorithmEnumMap, json['matching_algorithm']) ?? + MatchingAlgorithm.defaultValue, + isInsensitive: json['is_insensitive'] as bool? ?? true, documentCount: json['document_count'] as int?, ); 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 99111ed..1b78ccd 100644 --- a/packages/paperless_api/lib/src/models/labels/label_model.dart +++ b/packages/paperless_api/lib/src/models/labels/label_model.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/src/models/labels/matching_algorithm.dart'; abstract class Label extends Equatable implements Comparable { @@ -12,27 +11,21 @@ abstract class Label extends Equatable implements Comparable { static const documentCountKey = "document_count"; String get queryEndpoint; - @JsonKey() + final int? id; - @JsonKey() final String name; - @JsonKey() final String? slug; - @JsonKey() final String? match; - @JsonKey() final MatchingAlgorithm matchingAlgorithm; - @JsonKey() final bool? isInsensitive; - @JsonKey() final int? documentCount; const Label({ - required this.id, + this.id, required this.name, - required this.matchingAlgorithm, + this.matchingAlgorithm = MatchingAlgorithm.defaultValue, this.match, - this.isInsensitive, + this.isInsensitive = true, this.documentCount, this.slug, }); diff --git a/packages/paperless_api/lib/src/models/labels/matching_algorithm.dart b/packages/paperless_api/lib/src/models/labels/matching_algorithm.dart index 1d6fd4b..b4229f9 100644 --- a/packages/paperless_api/lib/src/models/labels/matching_algorithm.dart +++ b/packages/paperless_api/lib/src/models/labels/matching_algorithm.dart @@ -14,12 +14,5 @@ enum MatchingAlgorithm { const MatchingAlgorithm(this.value, this.name); - static MatchingAlgorithm fromInt(int? value) { - return MatchingAlgorithm.values - .where((element) => element.value == value) - .firstWhere( - (element) => true, - orElse: () => MatchingAlgorithm.anyWord, - ); - } + static const MatchingAlgorithm defaultValue = auto; } 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 index 0ac9c33..09566cc 100644 --- a/packages/paperless_api/lib/src/models/labels/storage_path_model.dart +++ b/packages/paperless_api/lib/src/models/labels/storage_path_model.dart @@ -6,17 +6,17 @@ part 'storage_path_model.g.dart'; @JsonSerializable(includeIfNull: false, fieldRename: FieldRename.snake) class StoragePath extends Label { static const pathKey = 'path'; - late String? path; + final String path; - StoragePath({ - required super.id, + const StoragePath({ + super.id, required super.name, + required this.path, super.slug, super.match, - required super.matchingAlgorithm, + super.matchingAlgorithm, super.isInsensitive, super.documentCount, - required this.path, }); factory StoragePath.fromJson(Map json) => diff --git a/packages/paperless_api/lib/src/models/labels/storage_path_model.g.dart b/packages/paperless_api/lib/src/models/labels/storage_path_model.g.dart index 8c1211e..5ae4ad4 100644 --- a/packages/paperless_api/lib/src/models/labels/storage_path_model.g.dart +++ b/packages/paperless_api/lib/src/models/labels/storage_path_model.g.dart @@ -9,13 +9,14 @@ part of 'storage_path_model.dart'; StoragePath _$StoragePathFromJson(Map json) => StoragePath( id: json['id'] as int?, name: json['name'] as String, + path: json['path'] as String, slug: json['slug'] as String?, match: json['match'] as String?, - matchingAlgorithm: - $enumDecode(_$MatchingAlgorithmEnumMap, json['matching_algorithm']), - isInsensitive: json['is_insensitive'] as bool?, + matchingAlgorithm: $enumDecodeNullable( + _$MatchingAlgorithmEnumMap, json['matching_algorithm']) ?? + MatchingAlgorithm.defaultValue, + isInsensitive: json['is_insensitive'] as bool? ?? true, documentCount: json['document_count'] as int?, - path: json['path'] as String?, ); Map _$StoragePathToJson(StoragePath instance) { @@ -35,7 +36,7 @@ Map _$StoragePathToJson(StoragePath instance) { _$MatchingAlgorithmEnumMap[instance.matchingAlgorithm]!; writeNotNull('is_insensitive', instance.isInsensitive); writeNotNull('document_count', instance.documentCount); - writeNotNull('path', instance.path); + val['path'] = instance.path; return val; } diff --git a/packages/paperless_api/lib/src/models/labels/tag_model.dart b/packages/paperless_api/lib/src/models/labels/tag_model.dart index 62c4523..8f22e05 100644 --- a/packages/paperless_api/lib/src/models/labels/tag_model.dart +++ b/packages/paperless_api/lib/src/models/labels/tag_model.dart @@ -1,44 +1,84 @@ import 'dart:developer'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; 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, constructor: "_") 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? _apiV2color; - - final Color? _apiV1color; - final Color? textColor; final bool? isInboxTag; - Color? get color => _apiV2color ?? _apiV1color; + @protected + @JsonKey(name: colorKey) + Color? colorv2; - const Tag({ - required super.id, + @protected + @Deprecated( + "Alias for the field color. Deprecated since Paperless API v2. Please use the color getter to access the background color of this tag.", + ) + @JsonKey(name: legacyColourKey) + Color? colorv1; + + Color? get color => colorv2 ?? colorv1; + + /// Constructor to use for serialization. + Tag._({ + super.id, required super.name, super.documentCount, - super.isInsensitive, + super.isInsensitive = true, super.match, - required super.matchingAlgorithm, + super.matchingAlgorithm, super.slug, - Color? color, + this.colorv1, + this.colorv2, this.textColor, - this.isInboxTag, - }) : _apiV1color = color, - _apiV2color = color; + this.isInboxTag = false, + }) { + colorv1 ??= colorv2; + colorv2 ??= colorv1; + } + + Tag({ + int? id, + required String name, + int? documentCount, + bool? isInsensitive, + String? match, + MatchingAlgorithm matchingAlgorithm = MatchingAlgorithm.defaultValue, + String? slug, + Color? color, + Color? textColor, + bool? isInboxTag, + }) : this._( + id: id, + name: name, + documentCount: documentCount, + isInsensitive: isInsensitive, + match: match, + matchingAlgorithm: matchingAlgorithm, + slug: slug, + textColor: textColor, + colorv1: color, + colorv2: color, + ); @override - String toString() { - return name; - } + String toString() => name; @override Tag copyWith({ @@ -84,89 +124,8 @@ class Tag extends Label { match, ]; - //FIXME: Why is this not generated?! - factory Tag.fromJson(Map json) { - const $MatchingAlgorithmEnumMap = { - MatchingAlgorithm.anyWord: 1, - MatchingAlgorithm.allWords: 2, - MatchingAlgorithm.exactMatch: 3, - MatchingAlgorithm.regex: 4, - MatchingAlgorithm.fuzzy: 5, - MatchingAlgorithm.auto: 6, - }; - - return Tag( - id: json['id'] as int?, - name: json['name'] as String, - documentCount: json['document_count'] as int?, - isInsensitive: json['is_insensitive'] as bool?, - match: json['match'] as String?, - matchingAlgorithm: - $enumDecode($MatchingAlgorithmEnumMap, json['matching_algorithm']), - slug: json['slug'] as String?, - textColor: _colorFromJson(json['text_color']), - isInboxTag: json['is_inbox_tag'] as bool?, - color: _colorFromJson(json['color']) ?? _colorFromJson(json['colour']), - ); - } + factory Tag.fromJson(Map json) => _$TagFromJson(json); @override - Map toJson() { - final val = {}; - - const $MatchingAlgorithmEnumMap = { - MatchingAlgorithm.anyWord: 1, - MatchingAlgorithm.allWords: 2, - MatchingAlgorithm.exactMatch: 3, - MatchingAlgorithm.regex: 4, - MatchingAlgorithm.fuzzy: 5, - MatchingAlgorithm.auto: 6, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('id', id); - val['name'] = name; - writeNotNull('slug', slug); - writeNotNull('match', match); - writeNotNull( - 'matching_algorithm', $MatchingAlgorithmEnumMap[matchingAlgorithm]); - writeNotNull('is_insensitive', isInsensitive); - writeNotNull('document_count', documentCount); - writeNotNull('color', _toHex(_apiV2color)); - writeNotNull('colour', _toHex(_apiV1color)); - writeNotNull('text_color', _toHex(textColor)); - writeNotNull('is_inbox_tag', isInboxTag); - return val; - } - - static Color? _colorFromJson(dynamic color) { - if (color is Color) { - return color; - } - if (color is String) { - final decoded = int.tryParse(color.replaceAll("#", "ff"), radix: 16); - if (decoded == null) { - return null; - } - return Color(decoded); - } - return null; - } - - /// - /// Taken from [FormBuilderColorPicker]. - /// - static String? _toHex(Color? color) { - if (color == null) { - return null; - } - String val = - '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}'; - return val; - } + Map toJson() => _$TagToJson(this); } diff --git a/packages/paperless_api/lib/src/models/labels/tag_model.g.dart b/packages/paperless_api/lib/src/models/labels/tag_model.g.dart new file mode 100644 index 0000000..9be6dc3 --- /dev/null +++ b/packages/paperless_api/lib/src/models/labels/tag_model.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tag_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Tag _$TagFromJson(Map json) => Tag._( + id: json['id'] as int?, + name: json['name'] as String, + documentCount: json['document_count'] as int?, + isInsensitive: json['is_insensitive'] as bool? ?? true, + match: json['match'] as String?, + matchingAlgorithm: $enumDecodeNullable( + _$MatchingAlgorithmEnumMap, json['matching_algorithm']) ?? + MatchingAlgorithm.defaultValue, + slug: json['slug'] as String?, + colorv1: const HexColorJsonConverter().fromJson(json['colour']), + colorv2: const HexColorJsonConverter().fromJson(json['color']), + textColor: const HexColorJsonConverter().fromJson(json['text_color']), + isInboxTag: json['is_inbox_tag'] as bool? ?? false, + ); + +Map _$TagToJson(Tag instance) => { + 'id': instance.id, + 'name': instance.name, + 'slug': instance.slug, + 'match': instance.match, + 'matching_algorithm': + _$MatchingAlgorithmEnumMap[instance.matchingAlgorithm]!, + 'is_insensitive': instance.isInsensitive, + 'document_count': instance.documentCount, + 'text_color': const HexColorJsonConverter().toJson(instance.textColor), + 'is_inbox_tag': instance.isInboxTag, + 'color': const HexColorJsonConverter().toJson(instance.colorv2), + 'colour': const HexColorJsonConverter().toJson(instance.colorv1), + }; + +const _$MatchingAlgorithmEnumMap = { + MatchingAlgorithm.anyWord: 1, + MatchingAlgorithm.allWords: 2, + MatchingAlgorithm.exactMatch: 3, + MatchingAlgorithm.regex: 4, + MatchingAlgorithm.fuzzy: 5, + MatchingAlgorithm.auto: 6, +}; diff --git a/packages/paperless_api/lib/src/models/models.dart b/packages/paperless_api/lib/src/models/models.dart index 3d43bd0..7ba46a8 100644 --- a/packages/paperless_api/lib/src/models/models.dart +++ b/packages/paperless_api/lib/src/models/models.dart @@ -21,7 +21,6 @@ export 'paperless_server_exception.dart'; export 'paperless_server_information_model.dart'; export 'paperless_server_statistics_model.dart'; export 'saved_view_model.dart'; -export 'similar_document_model.dart'; export 'task/task.dart'; export 'task/task_status.dart'; export 'field_suggestions.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 9beef0c..e426ed1 100644 --- a/packages/paperless_api/lib/src/models/paged_search_result.dart +++ b/packages/paperless_api/lib/src/models/paged_search_result.dart @@ -1,6 +1,5 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_api/src/models/document_model.dart'; const pageRegex = r".*page=(\d+).*"; @@ -108,5 +107,10 @@ class PagedSearchResult extends Equatable { } @override - List get props => [count, next, previous, results]; + List get props => [ + count, + next, + previous, + results, + ]; } 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 d5ac901..77cd188 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 @@ -8,6 +8,6 @@ class PaperlessServerStatisticsModel { }); PaperlessServerStatisticsModel.fromJson(Map json) - : documentsTotal = json['documents_total'], - documentsInInbox = json['documents_inbox']; + : documentsTotal = json['documents_total'] ?? 0, + documentsInInbox = json['documents_inbox'] ?? 0; } diff --git a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/absolute_date_range_query.dart b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/absolute_date_range_query.dart index 56d7ecc..73a3665 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/absolute_date_range_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/absolute_date_range_query.dart @@ -52,4 +52,17 @@ class AbsoluteDateRangeQuery extends DateRangeQuery { @override Map toJson() => _$AbsoluteDateRangeQueryToJson(this); + + @override + bool matches(DateTime dt) { + //TODO: Check if after and before are inclusive or exclusive definitions. + bool matches = true; + if (after != null) { + matches &= dt.isAfter(after!) || dt == after; + } + if (before != null) { + matches &= dt.isBefore(before!) || dt == before; + } + return matches; + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/date_range_query.dart b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/date_range_query.dart index 5ce1fe8..7a7c6f7 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/date_range_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/date_range_query.dart @@ -7,4 +7,6 @@ abstract class DateRangeQuery extends Equatable { Map toQueryParameter(DateRangeQueryField field); Map toJson(); + + bool matches(DateTime dt); } diff --git a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/relative_date_range_query.dart b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/relative_date_range_query.dart index ae435b1..ef5ea56 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/relative_date_range_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/relative_date_range_query.dart @@ -1,3 +1,4 @@ +import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; import 'date_range_query.dart'; @@ -35,9 +36,28 @@ class RelativeDateRangeQuery extends DateRangeQuery { ); } + /// Returns the datetime when subtracting the offset given the unit from now. + DateTime get dateTime { + switch (unit) { + case DateRangeUnit.day: + return Jiffy().subtract(days: offset).dateTime; + case DateRangeUnit.week: + return Jiffy().subtract(weeks: offset).dateTime; + case DateRangeUnit.month: + return Jiffy().subtract(months: offset).dateTime; + case DateRangeUnit.year: + return Jiffy().subtract(years: offset).dateTime; + } + } + @override Map toJson() => _$RelativeDateRangeQueryToJson(this); factory RelativeDateRangeQuery.fromJson(Map json) => _$RelativeDateRangeQueryFromJson(json); + + @override + bool matches(DateTime dt) { + return dt.isAfter(dateTime) || dt == dateTime; + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/unset_date_range_query.dart b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/unset_date_range_query.dart index 055130f..1a0e0aa 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/unset_date_range_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/date_range_queries/unset_date_range_query.dart @@ -14,4 +14,7 @@ class UnsetDateRangeQuery extends DateRangeQuery { Map toJson() { return {}; } + + @override + bool matches(DateTime dt) => true; } diff --git a/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart b/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart index 1890e2a..21e1884 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; part 'id_query_parameter.g.dart'; @@ -19,9 +18,7 @@ class IdQueryParameter extends Equatable { : assignmentStatus = 0, id = null; - const IdQueryParameter.fromId(int? id) - : assignmentStatus = null, - id = id; + const IdQueryParameter.fromId(this.id) : assignmentStatus = null; const IdQueryParameter.unset() : this.fromId(null); @@ -45,6 +42,13 @@ class IdQueryParameter extends Equatable { return params; } + bool matches(int? id) { + return onlyAssigned && id != null || + onlyNotAssigned && id == null || + isSet && id == this.id || + isUnset; + } + @override List get props => [assignmentStatus, id]; diff --git a/packages/paperless_api/lib/src/models/query_parameters/sort_field.dart b/packages/paperless_api/lib/src/models/query_parameters/sort_field.dart index cd337b5..7eec450 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/sort_field.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/sort_field.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -@JsonEnum() +@JsonEnum(valueField: 'queryString') enum SortField { archiveSerialNumber("archive_serial_number"), correspondentName("correspondent__name"), diff --git a/packages/paperless_api/lib/src/models/query_parameters/tags_query/any_assigned_tags_query.dart b/packages/paperless_api/lib/src/models/query_parameters/tags_query/any_assigned_tags_query.dart index adf5a25..36b1a03 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/tags_query/any_assigned_tags_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/tags_query/any_assigned_tags_query.dart @@ -27,4 +27,9 @@ class AnyAssignedTagsQuery extends TagsQuery { factory AnyAssignedTagsQuery.fromJson(Map json) => _$AnyAssignedTagsQueryFromJson(json); + + @override + bool matches(Iterable ids) { + return ids.isNotEmpty; + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/tags_query/ids_tags_query.dart b/packages/paperless_api/lib/src/models/query_parameters/tags_query/ids_tags_query.dart index 834fc14..f8802f0 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/tags_query/ids_tags_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/tags_query/ids_tags_query.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; - +import 'package:collection/collection.dart'; import 'exclude_tag_id_query.dart'; import 'include_tag_id_query.dart'; import 'tag_id_query.dart'; @@ -85,4 +85,10 @@ class IdsTagsQuery extends TagsQuery { (json['queries'] as List).map((e) => TagIdQuery.fromJson(e)), ); } + + @override + bool matches(Iterable ids) { + return includedIds.toSet().difference(ids.toSet()).isEmpty && + excludedIds.toSet().intersection(ids.toSet()).isEmpty; + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/tags_query/only_not_assigned_tags_query.dart b/packages/paperless_api/lib/src/models/query_parameters/tags_query/only_not_assigned_tags_query.dart index 0c0d937..6d24678 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/tags_query/only_not_assigned_tags_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/tags_query/only_not_assigned_tags_query.dart @@ -14,4 +14,9 @@ class OnlyNotAssignedTagsQuery extends TagsQuery { Map toJson() { return {}; } + + @override + bool matches(Iterable ids) { + return ids.isEmpty; + } } diff --git a/packages/paperless_api/lib/src/models/query_parameters/tags_query/tags_query.dart b/packages/paperless_api/lib/src/models/query_parameters/tags_query/tags_query.dart index b9de435..984d846 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/tags_query/tags_query.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/tags_query/tags_query.dart @@ -4,4 +4,6 @@ abstract class TagsQuery extends Equatable { const TagsQuery(); Map toQueryParameter(); Map toJson(); + + bool matches(Iterable ids); } 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 78b1123..fdebfb8 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 @@ -58,6 +58,26 @@ class TextQuery extends Equatable { return null; } + bool matches({ + required String title, + String? content, + int? asn, + }) { + if (queryText?.isEmpty ?? true) return true; + switch (queryType) { + case QueryType.title: + return title.contains(queryText!); + case QueryType.titleAndContent: + return title.contains(queryText!) || + (content?.contains(queryText!) ?? false); + case QueryType.extended: + //TODO: Implement. Might be too complex... + return true; + case QueryType.asn: + return int.tryParse(queryText!) == asn; + } + } + Map toJson() => _$TextQueryToJson(this); factory TextQuery.fromJson(Map json) => 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 4dfeda0..ae89590 100644 --- a/packages/paperless_api/lib/src/models/saved_view_model.dart +++ b/packages/paperless_api/lib/src/models/saved_view_model.dart @@ -1,9 +1,13 @@ import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/src/models/document_filter.dart'; import 'package:paperless_api/src/models/filter_rule_model.dart'; import 'package:paperless_api/src/models/query_parameters/sort_field.dart'; import 'package:paperless_api/src/models/query_parameters/sort_order.dart'; +part 'saved_view_model.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake) class SavedView with EquatableMixin { final int? id; final String name; @@ -11,7 +15,7 @@ class SavedView with EquatableMixin { final bool showOnDashboard; final bool showInSidebar; - final SortField sortField; + final SortField? sortField; final bool sortReverse; final List filterRules; @@ -20,7 +24,7 @@ class SavedView with EquatableMixin { required this.name, required this.showOnDashboard, required this.showInSidebar, - required this.sortField, + this.sortField, required this.sortReverse, required this.filterRules, }) { @@ -41,21 +45,10 @@ class SavedView with EquatableMixin { filterRules ]; - SavedView.fromJson(Map json) - : this( - id: json['id'], - name: json['name'], - showOnDashboard: json['show_on_dashboard'], - showInSidebar: json['show_in_sidebar'], - sortField: SortField.values - .where((order) => order.queryString == json['sort_field']) - .first, - sortReverse: json['sort_reverse'], - filterRules: (json['filter_rules'] as List) - .cast>() - .map(FilterRule.fromJson) - .toList(), - ); + factory SavedView.fromJson(Map json) => + _$SavedViewFromJson(json); + + Map toJson() => _$SavedViewToJson(this); DocumentFilter toDocumentFilter() { return filterRules.fold( @@ -81,16 +74,4 @@ class SavedView with EquatableMixin { showOnDashboard: showOnDashboard, sortReverse: filter.sortOrder == SortOrder.descending, ); - - Map toJson() { - return { - 'id': id, - 'name': name, - 'show_on_dashboard': showOnDashboard, - 'show_in_sidebar': showInSidebar, - 'sort_reverse': sortReverse, - 'sort_field': sortField.queryString, - 'filter_rules': filterRules.map((rule) => rule.toJson()).toList(), - }; - } } diff --git a/packages/paperless_api/lib/src/models/saved_view_model.g.dart b/packages/paperless_api/lib/src/models/saved_view_model.g.dart new file mode 100644 index 0000000..012143a --- /dev/null +++ b/packages/paperless_api/lib/src/models/saved_view_model.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'saved_view_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SavedView _$SavedViewFromJson(Map json) => SavedView( + id: json['id'] as int?, + name: json['name'] as String, + showOnDashboard: json['show_on_dashboard'] as bool, + showInSidebar: json['show_in_sidebar'] as bool, + sortField: $enumDecodeNullable(_$SortFieldEnumMap, json['sort_field']), + sortReverse: json['sort_reverse'] as bool, + filterRules: (json['filter_rules'] as List) + .map((e) => FilterRule.fromJson(e as Map)) + .toList(), + ); + +Map _$SavedViewToJson(SavedView instance) => { + 'id': instance.id, + 'name': instance.name, + 'show_on_dashboard': instance.showOnDashboard, + 'show_in_sidebar': instance.showInSidebar, + 'sort_field': _$SortFieldEnumMap[instance.sortField], + 'sort_reverse': instance.sortReverse, + 'filter_rules': instance.filterRules, + }; + +const _$SortFieldEnumMap = { + SortField.archiveSerialNumber: 'archive_serial_number', + SortField.correspondentName: 'correspondent__name', + SortField.title: 'title', + SortField.documentType: 'document_type__name', + SortField.created: 'created', + SortField.added: 'added', + SortField.modified: 'modified', +}; diff --git a/packages/paperless_api/lib/src/models/similar_document_model.dart b/packages/paperless_api/lib/src/models/similar_document_model.dart deleted file mode 100644 index 173b270..0000000 --- a/packages/paperless_api/lib/src/models/similar_document_model.dart +++ /dev/null @@ -1,36 +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/document_model.dart'; -import 'package:paperless_api/src/models/search_hit.dart'; - -part 'similar_document_model.g.dart'; - -@LocalDateTimeJsonConverter() -@JsonSerializable() -class SimilarDocumentModel extends DocumentModel { - @JsonKey(name: '__search_hit__') - final SearchHit searchHit; - - const SimilarDocumentModel({ - required super.id, - required super.title, - required super.documentType, - required super.correspondent, - required super.created, - required super.modified, - required super.added, - required super.originalFileName, - required this.searchHit, - super.archiveSerialNumber, - super.archivedFileName, - super.content, - super.storagePath, - super.tags, - }); - - factory SimilarDocumentModel.fromJson(Map json) => - _$SimilarDocumentModelFromJson(json); - - @override - Map toJson() => _$SimilarDocumentModelToJson(this); -} diff --git a/packages/paperless_api/lib/src/models/similar_document_model.g.dart b/packages/paperless_api/lib/src/models/similar_document_model.g.dart deleted file mode 100644 index d2f996d..0000000 --- a/packages/paperless_api/lib/src/models/similar_document_model.g.dart +++ /dev/null @@ -1,50 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'similar_document_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SimilarDocumentModel _$SimilarDocumentModelFromJson( - Map json) => - SimilarDocumentModel( - id: json['id'] as int, - title: json['title'] as String, - documentType: json['documentType'] as int?, - correspondent: json['correspondent'] as int?, - created: const LocalDateTimeJsonConverter() - .fromJson(json['created'] as String), - modified: const LocalDateTimeJsonConverter() - .fromJson(json['modified'] as String), - added: - const LocalDateTimeJsonConverter().fromJson(json['added'] as String), - originalFileName: json['originalFileName'] as String, - searchHit: - SearchHit.fromJson(json['__search_hit__'] as Map), - archiveSerialNumber: json['archiveSerialNumber'] as int?, - archivedFileName: json['archivedFileName'] as String?, - content: json['content'] as String?, - storagePath: json['storagePath'] as int?, - tags: (json['tags'] as List?)?.map((e) => e as int) ?? - const [], - ); - -Map _$SimilarDocumentModelToJson( - SimilarDocumentModel instance) => - { - 'id': instance.id, - 'title': instance.title, - 'content': instance.content, - 'tags': instance.tags.toList(), - 'documentType': instance.documentType, - 'correspondent': instance.correspondent, - 'storagePath': instance.storagePath, - 'created': const LocalDateTimeJsonConverter().toJson(instance.created), - 'modified': const LocalDateTimeJsonConverter().toJson(instance.modified), - 'added': const LocalDateTimeJsonConverter().toJson(instance.added), - 'archiveSerialNumber': instance.archiveSerialNumber, - 'originalFileName': instance.originalFileName, - 'archivedFileName': instance.archivedFileName, - '__search_hit__': instance.searchHit, - }; 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 340469b..aabd746 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 @@ -18,7 +18,6 @@ abstract class PaperlessDocumentsApi { Future findNextAsn(); Future> findAll(DocumentFilter filter); Future find(int id); - Future> findSimilar(int docId); Future delete(DocumentModel doc); Future getMetaData(DocumentModel document); Future> bulkAction(BulkAction action); 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 9b99bd7..0536ddb 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 @@ -241,27 +241,6 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } } - @override - Future> findSimilar(int docId) async { - try { - final response = - await client.get("/api/documents/?more_like=$docId&pageSize=10"); - if (response.statusCode == 200) { - return (await compute( - PagedSearchResult.fromJsonSingleParam, - PagedSearchResultJsonSerializer( - response.data, - SimilarDocumentModelJsonConverter(), - ), - )) - .results; - } - throw const PaperlessServerException(ErrorCode.similarQueryError); - } on DioError catch (err) { - throw err.error; - } - } - @override Future findSuggestions(DocumentModel document) async { try { diff --git a/packages/paperless_api/pubspec.yaml b/packages/paperless_api/pubspec.yaml index 91162d0..396538a 100644 --- a/packages/paperless_api/pubspec.yaml +++ b/packages/paperless_api/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: intl: ^0.17.0 dio: ^4.0.6 collection: ^1.17.0 + jiffy: ^5.0.0 dev_dependencies: flutter_test: diff --git a/pubspec.lock b/pubspec.lock index 1ae465a..c993146 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a" + url: "https://pub.dev" + source: hosted + version: "2.0.1" archive: dependency: transitive description: @@ -29,10 +45,10 @@ packages: dependency: transitive description: name: args - sha256: b003c3098049a51720352d219b0bb5f219b60fbfb68e7a4748139a06a5676515 + sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" asn1lib: dependency: transitive description: @@ -49,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.10.0" + auto_route: + dependency: transitive + description: + name: auto_route + sha256: "12047baeca0e01df93165ef33275b32119d72699ab9a49dc64c20e78f586f96d" + url: "https://pub.dev" + source: hosted + version: "5.0.4" + auto_route_generator: + dependency: "direct dev" + description: + name: auto_route_generator + sha256: de5bfbc02ae4eebb339dd90d325749ae7536e903f6513ef72b88954072d72b0e + url: "https://pub.dev" + source: hosted + version: "5.0.3" badges: dependency: "direct main" description: @@ -149,10 +181,10 @@ packages: dependency: transitive description: name: built_value - sha256: "59e08b0079bb75f7e27392498e26339387c1089c6bd58525a14eb8508637277b" + sha256: "169565c8ad06adb760c3645bf71f00bff161b00002cace266cad42c5d22a7725" url: "https://pub.dev" source: hosted - version: "8.4.2" + version: "8.4.3" cached_network_image: dependency: "direct main" description: @@ -213,10 +245,10 @@ packages: dependency: "direct main" description: name: collection - sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.17.1" connectivity_plus: dependency: "direct main" description: @@ -277,18 +309,18 @@ packages: dependency: transitive description: name: coverage - sha256: d2494157c32b303f47dedee955b1479f2979c4ff66934eb7c0def44fd9e0267a + sha256: "961c4aebd27917269b1896382c7cb1b1ba81629ba669ba09c27a7e5710ec9040" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "1.6.2" cross_file: dependency: transitive description: name: cross_file - sha256: f71079978789bc2fe78d79227f1f8cfe195b31bbd8db2399b0d15a4b96fb843b + sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" url: "https://pub.dev" source: hosted - version: "0.3.3+2" + version: "0.3.3+4" crypto: dependency: transitive description: @@ -305,6 +337,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.2" + dart_code_metrics: + dependency: "direct dev" + description: + name: dart_code_metrics + sha256: bb4ec5e729788dde5f7e8e9df4c05ec3b78532a5763e635337153ce40085514b + url: "https://pub.dev" + source: hosted + version: "5.5.1" + dart_code_metrics_presets: + dependency: transitive + description: + name: dart_code_metrics_presets + sha256: "43dc1fdcb424fc3aa79964304d09eeda4f199351c52cdc854f8228a9d0296b60" + url: "https://pub.dev" + source: hosted + version: "1.1.0" dart_style: dependency: transitive description: @@ -401,12 +449,20 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: c4a508284b14ec4dda5adba2c28b2cdd34fbae1afead7e8c52cad87d51c5405b + url: "https://pub.dev" + source: hosted + version: "1.6.2" edge_detection: dependency: "direct main" description: path: "." ref: master - resolved-ref: "8c80e3a6e231985763ff501ad7ae12d76995a2e8" + resolved-ref: "24da81d7cb3bc6418d5901da355addb337793b46" url: "https://github.com/sawankumarbundelkhandi/edge_detection" source: git version: "1.1.1" @@ -470,18 +526,18 @@ packages: dependency: "direct main" description: name: file_picker - sha256: ecf52f978e72763ede54a93271318bbbca65a2be2d9ff658ec8ca4ea3a23d7ef + sha256: d090ae03df98b0247b82e5928f44d1b959867049d18d73635e2e0bc3f49542b9 url: "https://pub.dev" source: hosted - version: "5.2.4" + version: "5.2.5" fixnum: dependency: transitive description: name: fixnum - sha256: "04be3e934c52e082558cc9ee21f42f5c1cd7a1262f4c63cd0357c08d5bba81ec" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -519,6 +575,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + flutter_displaymode: + dependency: "direct main" + description: + name: flutter_displaymode + sha256: "136b0314fdc78fe995b0b75061fe9ff8210dffca84f8f8110f8f71029479db3b" + url: "https://pub.dev" + source: hosted + version: "0.5.0" flutter_driver: dependency: transitive description: flutter @@ -658,10 +722,10 @@ packages: dependency: "direct main" description: name: flutter_typeahead - sha256: "0ec56e1deac7556f3616f3cd53c9a25bf225dc8b72e9f44b5a7717e42bb467b5" + sha256: "73eb76fa640ea630e2d957e7b469ab2b91e4da6c4950d6032fab7009275637b7" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.3.3" flutter_web_plugins: dependency: transitive description: flutter @@ -809,42 +873,50 @@ packages: dependency: "direct main" description: name: introduction_screen - sha256: "26d06cff940b9f3f1ec6591a6beea4da31183574b279c373e142ca76882ce9ea" + sha256: "73965475d6b271846f81c5fce5b459546a4ea36c285408691522437fd6bbeb69" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" + jiffy: + dependency: transitive + description: + name: jiffy + sha256: "85172c4fc975a50224521c05bf43abc845288863b19d91bd3c221a96a8785dd3" + url: "https://pub.dev" + source: hosted + version: "5.0.0" js: dependency: transitive description: name: js - sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.6.5" + version: "0.6.7" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: "3520fa844009431b5d4491a5a778603520cdc399ab3406332dcc50f93547258c" + sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.8.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: f3c2c18a7889580f71926f30c1937727c8c7d4f3a435f8f5e8b0ddd25253ef5d + sha256: dadc08bd61f72559f938dd08ec20dbfec6c709bba83515085ea943d2078d187a url: "https://pub.dev" source: hosted - version: "6.5.4" + version: "6.6.1" lints: dependency: transitive description: @@ -889,18 +961,18 @@ packages: dependency: transitive description: name: local_auth_windows - sha256: "53ef7487587e1cb06755861a9a74585b3b361ba1969ad374c728c75771a14fbb" + sha256: "888482e4f9ca3560e00bc227ce2badeb4857aad450c42a31c6cfc9dc21e0ccbc" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" logging: dependency: transitive description: name: logging - sha256: c0bbfe94d46aedf9b8b3e695cf3bd48c8e14b35e3b2c639e0aa7755d589ba946 + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" matcher: dependency: transitive description: @@ -921,18 +993,18 @@ packages: dependency: transitive description: name: meta - sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + sha256: "12307e7f0605ce3da64cf0db90e5fcab0869f3ca03f76be6bb2991ce0a55e82b" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.0" mime: dependency: "direct main" description: name: mime - sha256: "52e38f7e1143ef39daf532117d6b8f8f617bf4bcd6044ed8c29040d20d269630" + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" mockito: dependency: "direct dev" description: @@ -1080,10 +1152,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "050e8e85e4b7fecdf2bb3682c1c64c4887a183720c802d323de8a5fd76d372dd" + sha256: dcea5feb97d8abf90cab9e9030b497fb7c3cbf26b7a1fe9e3ef7dcb0a1ddec95 url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.0.12" path_provider_android: dependency: transitive description: @@ -1092,14 +1164,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.22" - path_provider_ios: + path_provider_foundation: dependency: transitive description: - name: path_provider_ios - sha256: "03d639406f5343478352433f00d3c4394d52dac8df3d847869c5e2333e0bbce8" + name: path_provider_foundation + sha256: "62a68e7e1c6c459f9289859e2fae58290c981ce21d1697faf54910fe1faa4c74" url: "https://pub.dev" source: hosted - version: "2.0.11" + version: "2.1.1" path_provider_linux: dependency: transitive description: @@ -1108,14 +1180,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.7" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - sha256: "2a97e7fbb7ae9dcd0dfc1220a78e9ec3e71da691912e617e8715ff2a13086ae8" - url: "https://pub.dev" - source: hosted - version: "2.0.6" path_provider_platform_interface: dependency: transitive description: @@ -1276,6 +1340,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "42890302ab2672adf567dc2b20e55b4ecc29d7e19c63b6b98143ab68dd717d3a" + url: "https://pub.dev" + source: hosted + version: "0.2.4" pubspec_parse: dependency: transitive description: @@ -1336,42 +1408,34 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "76917b7d4b9526b2ba416808a7eb9fb2863c1a09cf63ec85f1453da240fa818a" + sha256: "5949029e70abe87f75cfe59d17bf5c397619c4b74a099b10116baeb34786fad9" url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.0.17" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8e251f3c986002b65fed6396bce81f379fb63c27317d49743cf289fd0fd1ab97" + sha256: "955e9736a12ba776bdd261cf030232b30eadfcd9c79b32a3250dd4a494e8c8f7" url: "https://pub.dev" source: hosted - version: "2.0.14" - shared_preferences_ios: + version: "2.0.15" + shared_preferences_foundation: dependency: transitive description: - name: shared_preferences_ios - sha256: "585a14cefec7da8c9c2fb8cd283a3bb726b4155c0952afe6a0caaa7b2272de34" + name: shared_preferences_foundation + sha256: "2b55c18636a4edc529fa5cd44c03d3f3100c00513f518c5127c951978efcccd0" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.3" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: fbc3cd6826896b66a5f576b025e4f344f780c84ea7f8203097a353370607a2c8 + sha256: f8ea038aa6da37090093974ebdcf4397010605fd2ff65c37a66f9d28394cb874 url: "https://pub.dev" source: hosted - version: "2.1.2" - shared_preferences_macos: - dependency: transitive - description: - name: shared_preferences_macos - sha256: fbb94bf296576f49be37a1496d5951796211a8db0aa22cc0d68c46440dad808c - url: "https://pub.dev" - source: hosted - version: "2.0.4" + version: "2.1.3" shared_preferences_platform_interface: dependency: transitive description: @@ -1392,10 +1456,10 @@ packages: dependency: transitive description: name: shared_preferences_windows - sha256: "07c274c2115d4d5e4280622abb09f0980e2c5b1fcdc98ae9f59a3bad5bfc1f26" + sha256: "5eaf05ae77658d3521d0e993ede1af962d4b326cd2153d312df716dc250f00c9" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" shelf: dependency: transitive description: @@ -1445,10 +1509,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "2d79738b6bbf38a43920e2b8d189e9a3ce6cc201f4b8fc76be5e4fe377b1c38d" + sha256: c2bea18c95cfa0276a366270afaa2850b09b4a76db95d546f3d003dcc7011298 url: "https://pub.dev" source: hosted - version: "1.2.6" + version: "1.2.7" source_helper: dependency: transitive description: @@ -1485,18 +1549,18 @@ packages: dependency: transitive description: name: sqflite - sha256: "2b1697c7b78576fdc722c358f16f62171bd56e92dc13422d9e44be3fc446c276" + sha256: "78324387dc81df14f78df06019175a86a2ee0437624166c382e145d0a7fd9a4f" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.4+1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "0c21a187d645aa65da5be6997c0c713eed61e049158870ae2de157e6897067ab" + sha256: bfd6973aaeeb93475bc0d875ac9aefddf7965ef22ce09790eb963992ffc5183f url: "https://pub.dev" source: hosted - version: "2.4.0+2" + version: "2.4.2+2" stack_trace: dependency: transitive description: @@ -1541,10 +1605,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "7b530acd9cb7c71b0019a1e7fa22c4105e675557a4400b6a401c71c5e0ade1ac" + sha256: "33b31b6beb98100bf9add464a36a8dd03eb10c7a8cf15aeec535e9b054aaf04b" url: "https://pub.dev" source: hosted - version: "3.0.0+3" + version: "3.0.1" term_glyph: dependency: transitive description: @@ -1557,26 +1621,26 @@ packages: dependency: transitive description: name: test - sha256: "98403d1090ac0aa9e33dfc8bf45cc2e0c1d5c58d7cb832cee1e50bf14f37961d" + sha256: b54d427664c00f2013ffb87797a698883c46aee9288e027a50b46eaee7486fa2 url: "https://pub.dev" source: hosted - version: "1.22.1" + version: "1.22.2" test_api: dependency: transitive description: name: test_api - sha256: c9282698e2982b6c3817037554e52f99d4daba493e8028f8112a83d68ccd0b12 + sha256: "6182294da5abf431177fccc1ee02401f6df30f766bc6130a0852c6b6d7ee6b2d" url: "https://pub.dev" source: hosted - version: "0.4.17" + version: "0.4.18" test_core: dependency: transitive description: name: test_core - sha256: c9e4661a5e6285b795d47ba27957ed8b6f980fc020e98b218e276e88aff02168 + sha256: "95ecc12692d0dd59080ab2d38d9cf32c7e9844caba23ff6cd285690398ee8ef4" url: "https://pub.dev" source: hosted - version: "0.4.21" + version: "0.4.22" timezone: dependency: transitive description: @@ -1589,10 +1653,10 @@ packages: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: @@ -1621,42 +1685,42 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "3c92b0efb5e9dcb8f846aefabf9f0f739f91682ed486b991ceda51c288e60896" + sha256: "698fa0b4392effdc73e9e184403b627362eb5fbf904483ac9defbb1c2191d809" url: "https://pub.dev" source: hosted - version: "6.1.7" + version: "6.1.8" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "6f91d30ce9060c204b2dbe728adb300750fa4b228e8f7ed1b961aa1ceb728799" + sha256: "3e2f6dfd2c7d9cd123296cab8ef66cfc2c1a13f5845f42c7a0f365690a8a7dd1" url: "https://pub.dev" source: hosted - version: "6.0.22" + version: "6.0.23" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6ba7dddee26c9fae27c9203c424631109d73c8fa26cfa7bc3e35e751cb87f62e" + sha256: bb328b24d3bccc20bdf1024a0990ac4f869d57663660de9c936fb8c043edefe3 url: "https://pub.dev" source: hosted - version: "6.0.17" + version: "6.0.18" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "360fa359ab06bcb4f7c5cd3123a2a9a4d3364d4575d27c4b33468bd4497dd094" + sha256: "318c42cba924e18180c029be69caf0a1a710191b9ec49bb42b5998fdcccee3cc" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: a9b3ea9043eabfaadfa3fb89de67a11210d85569086d22b3854484beab8b3978 + sha256: "41988b55570df53b3dd2a7fc90c76756a963de6a8c5f8e113330cb35992e2094" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" url_launcher_platform_interface: dependency: transitive description: @@ -1669,18 +1733,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "5669882643b96bb6d5786637cac727c6e918a790053b09245fd4513b8a07df2a" + sha256: "44d79408ce9f07052095ef1f9a693c258d6373dc3944249374e30eff7219ccb0" url: "https://pub.dev" source: hosted - version: "2.0.13" + version: "2.0.14" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: e3c3b16d3104260c10eea3b0e34272aaa57921f83148b0619f74c2eced9b7ef1 + sha256: b6217370f8eb1fd85c8890c539f5a639a01ab209a36db82c921ebeacefc7a615 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" uuid: dependency: "direct main" description: @@ -1701,10 +1765,10 @@ packages: dependency: transitive description: name: vm_service - sha256: e7fb6c2282f7631712b69c19d1bff82f3767eea33a2321c14fa59ad67ea391c7 + sha256: "2277c73618916ae3c2082b6df67b6ebb64b4c69d9bf23b23700707952ac30e60" url: "https://pub.dev" source: hosted - version: "9.4.0" + version: "10.1.2" watcher: dependency: transitive description: @@ -1717,18 +1781,18 @@ packages: dependency: "direct main" description: name: web_socket_channel - sha256: "3a969ddcc204a3e34e863d204b29c0752716f78b6f9cc8235083208d268a4ccd" + sha256: ca49c0bc209c687b887f30527fb6a9d80040b072cc2990f34b9bec3e7663101b url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" webdriver: dependency: transitive description: name: webdriver - sha256: ef67178f0cc7e32c1494645b11639dd1335f1d18814aa8435113a92e9ef9d841 + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" webkit_inspection_protocol: dependency: transitive description: @@ -1749,10 +1813,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: "11541eedefbcaec9de35aa82650b695297ce668662bbd6e3911a7fabdbde589f" + sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86 url: "https://pub.dev" source: hosted - version: "0.2.0+2" + version: "0.2.0+3" xml: dependency: transitive description: @@ -1771,4 +1835,4 @@ packages: version: "3.1.1" sdks: dart: ">=3.0.0-35.0.dev <4.0.0" - flutter: ">=3.3.0" + flutter: ">=3.4.0-17.0.pre" diff --git a/pubspec.yaml b/pubspec.yaml index eb18cd7..9e5b860 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,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: 1.5.2+15 +version: 2.0.0+17 environment: sdk: '>=3.0.0-35.0.dev <4.0.0' @@ -68,7 +68,7 @@ dependencies: mime: ^1.0.2 receive_sharing_intent: ^1.4.5 uuid: ^3.0.6 - flutter_typeahead: ^4.1.1 + flutter_typeahead: ^4.3.3 fluttertoast: ^8.1.1 paperless_api: path: packages/paperless_api @@ -87,6 +87,9 @@ dependencies: flutter_staggered_grid_view: ^0.6.2 responsive_builder: ^0.4.3 open_filex: ^4.3.2 + flutter_displaymode: ^0.5.0 + dynamic_color: ^1.5.4 + dev_dependencies: integration_test: @@ -100,6 +103,8 @@ dev_dependencies: intl_utils: ^2.7.0 flutter_lints: ^1.0.0 json_serializable: ^6.5.4 + dart_code_metrics: ^5.4.0 + auto_route_generator: ^5.0.3 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -146,7 +151,6 @@ flutter: flutter_intl: enabled: true main_locale: en - localizely: project_id: 84b4144d-a628-4ba6-a8d0-4f9917444057 download_empty_as: main