From a4c4726c1640cf37b2f1e6d803799b7334adcca8 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Wed, 11 Jan 2023 01:26:36 +0100 Subject: [PATCH] Hooked notifications to status changes on document upload - some refactorings --- README.md | 2 +- lib/core/bloc/document_status_cubit.dart | 4 - .../paperless_server_information_cubit.dart | 11 +- .../logic/error_code_localization_mapper.dart | 4 + ..._dio_manager.dart => session_manager.dart} | 32 +- .../hydrated_storage_extension.dart | 14 + .../bloc/document_details_cubit.dart | 24 +- .../bloc/document_details_state.dart | 27 +- .../view/pages/document_details_page.dart | 56 +++- .../cubit/document_upload_cubit.dart | 10 +- .../document_upload_preparation_page.dart | 7 +- .../documents/bloc/documents_cubit.dart | 22 +- .../documents/bloc/documents_state.dart | 2 + .../view/pages/document_edit_page.dart | 207 +++++++++---- .../documents/view/pages/documents_page.dart | 283 ++++++++++++------ .../view/widgets/grid/document_grid.dart | 65 ---- .../view/widgets/grid/document_grid_item.dart | 8 +- .../widgets/list/adaptive_documents_view.dart | 118 ++++++++ .../view/widgets/list/document_list.dart | 73 ----- .../view/widgets/list/document_list_item.dart | 1 + .../widgets/new_items_loading_widget.dart | 10 + .../selection/documents_page_app_bar.dart | 16 + lib/features/home/view/home_page.dart | 49 +-- lib/features/inbox/bloc/inbox_cubit.dart | 3 +- .../labels/tags/view/widgets/tags_widget.dart | 4 +- .../bloc/linked_documents_cubit.dart | 2 +- .../view/pages/linked_documents_page.dart | 76 ++--- .../login/bloc/authentication_cubit.dart | 4 +- .../services/authentication_service.dart | 2 - .../cubit/notification_cubit.dart | 11 - .../cubit/notification_state.dart | 16 - .../services/local_notification_service.dart | 6 +- .../view/saved_view_selection_widget.dart | 188 +++++++----- lib/features/scan/view/scanner_page.dart | 7 +- .../tasks/cubit/task_status_cubit.dart | 24 +- .../tasks/cubit/task_status_state.dart | 9 +- lib/l10n/intl_cs.arb | 64 ++-- lib/l10n/intl_de.arb | 4 + lib/l10n/intl_en.arb | 4 + lib/main.dart | 62 ++-- .../lib/src/models/document_model.dart | 2 +- .../lib/src/models/field_suggestions.dart | 45 +++ .../lib/src/models/field_suggestions.g.dart | 33 ++ .../lib/src/models/filter_rule_model.dart | 20 +- .../paperless_api/lib/src/models/models.dart | 1 + .../models/paperless_server_exception.dart | 4 +- .../paperless_server_information_model.dart | 7 + .../lib/src/models/task/task.dart | 32 +- .../lib/src/models/task/task.g.dart | 5 +- .../paperless_documents_api.dart | 15 +- .../paperless_documents_api_impl.dart | 61 ++-- .../tasks_api/paperless_tasks_api.dart | 2 + .../tasks_api/paperless_tasks_api_impl.dart | 72 +++-- pubspec.lock | 56 +--- pubspec.yaml | 3 +- 55 files changed, 1128 insertions(+), 761 deletions(-) rename lib/core/security/{authentication_aware_dio_manager.dart => session_manager.dart} (67%) create mode 100644 lib/extensions/hydrated_storage_extension.dart delete mode 100644 lib/features/documents/view/widgets/grid/document_grid.dart create mode 100644 lib/features/documents/view/widgets/list/adaptive_documents_view.dart delete mode 100644 lib/features/documents/view/widgets/list/document_list.dart create mode 100644 lib/features/documents/view/widgets/new_items_loading_widget.dart delete mode 100644 lib/features/notifications/cubit/notification_cubit.dart delete mode 100644 lib/features/notifications/cubit/notification_state.dart create mode 100644 packages/paperless_api/lib/src/models/field_suggestions.dart create mode 100644 packages/paperless_api/lib/src/models/field_suggestions.g.dart diff --git a/README.md b/README.md index cf021fd..5eccb64 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ To get a local copy up and running follow these simple steps. ```sh flutter pub get ``` -3. Build generated files (e.g. for injectable library) +3. Build generated files (for json_serializable etc.) ```sh flutter packages pub run build_runner build --delete-conflicting-outputs ``` diff --git a/lib/core/bloc/document_status_cubit.dart b/lib/core/bloc/document_status_cubit.dart index 893d091..84121dd 100644 --- a/lib/core/bloc/document_status_cubit.dart +++ b/lib/core/bloc/document_status_cubit.dart @@ -1,10 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/core/model/document_processing_status.dart'; -import 'package:injectable/injectable.dart'; -@prod -@test -@lazySingleton class DocumentStatusCubit extends Cubit { DocumentStatusCubit() : super(null); diff --git a/lib/core/bloc/paperless_server_information_cubit.dart b/lib/core/bloc/paperless_server_information_cubit.dart index d5f45ac..3c48eca 100644 --- a/lib/core/bloc/paperless_server_information_cubit.dart +++ b/lib/core/bloc/paperless_server_information_cubit.dart @@ -1,20 +1,17 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:injectable/injectable.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'; -@prod -@test -@lazySingleton class PaperlessServerInformationCubit extends Cubit { - final PaperlessServerStatsApi service; + final PaperlessServerStatsApi _api; - PaperlessServerInformationCubit(this.service) + PaperlessServerInformationCubit(this._api) : super(PaperlessServerInformationState()); Future updateInformtion() async { - final information = await service.getServerInformation(); + final information = await _api.getServerInformation(); emit(PaperlessServerInformationState( isLoaded: true, information: information, diff --git a/lib/core/logic/error_code_localization_mapper.dart b/lib/core/logic/error_code_localization_mapper.dart index cdb0d4b..ccc49ff 100644 --- a/lib/core/logic/error_code_localization_mapper.dart +++ b/lib/core/logic/error_code_localization_mapper.dart @@ -68,5 +68,9 @@ String translateError(BuildContext context, ErrorCode code) { return S.of(context).errorMessageUnsupportedFileFormat; case ErrorCode.missingClientCertificate: return S.of(context).errorMessageMissingClientCertificate; + case ErrorCode.suggestionsQueryError: + return S.of(context).errorMessageSuggestionsQueryError; + case ErrorCode.acknowledgeTasksError: + return S.of(context).errorMessageAcknowledgeTasksError; } } diff --git a/lib/core/security/authentication_aware_dio_manager.dart b/lib/core/security/session_manager.dart similarity index 67% rename from lib/core/security/authentication_aware_dio_manager.dart rename to lib/core/security/session_manager.dart index 888b927..39c2c2c 100644 --- a/lib/core/security/authentication_aware_dio_manager.dart +++ b/lib/core/security/session_manager.dart @@ -2,15 +2,20 @@ import 'dart:io'; import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; -class AuthenticationAwareDioManager { +class SessionManager { final Dio client; final List interceptors; + PaperlessServerInformationModel serverInformation; - AuthenticationAwareDioManager([this.interceptors = const []]) - : client = _initDio(interceptors); + SessionManager([this.interceptors = const []]) + : client = _initDio(interceptors), + serverInformation = PaperlessServerInformationModel(); static Dio _initDio(List interceptors) { //en- and decoded by utf8 by default @@ -19,8 +24,19 @@ class AuthenticationAwareDioManager { dio.options.responseType = ResponseType.json; (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) => client..badCertificateCallback = (cert, host, port) => true; - dio.interceptors.addAll(interceptors); - dio.interceptors.add(RetryOnConnectionChangeInterceptor(dio: dio)); + dio.interceptors.addAll([ + ...interceptors, + DioHttpErrorInterceptor(), + PrettyDioLogger( + compact: true, + responseBody: false, + responseHeader: false, + request: false, + requestBody: false, + requestHeader: false, + ), + RetryOnConnectionChangeInterceptor(dio: dio) + ]); return dio; } @@ -28,6 +44,7 @@ class AuthenticationAwareDioManager { String? baseUrl, String? authToken, ClientCertificate? clientCertificate, + PaperlessServerInformationModel? serverInformation, }) { if (clientCertificate != null) { final context = SecurityContext() @@ -58,11 +75,16 @@ class AuthenticationAwareDioManager { if (authToken != null) { client.options.headers.addAll({'Authorization': 'Token $authToken'}); } + + if (serverInformation != null) { + this.serverInformation = serverInformation; + } } void resetSettings() { client.httpClientAdapter = DefaultHttpClientAdapter(); client.options.baseUrl = ''; client.options.headers.remove('Authorization'); + serverInformation = PaperlessServerInformationModel(); } } diff --git a/lib/extensions/hydrated_storage_extension.dart b/lib/extensions/hydrated_storage_extension.dart new file mode 100644 index 0000000..ef241e1 --- /dev/null +++ b/lib/extensions/hydrated_storage_extension.dart @@ -0,0 +1,14 @@ +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'; + +extension AddressableHydratedStorage on Storage { + ApplicationSettingsState get settings { + return ApplicationSettingsState.fromJson(read('ApplicationSettingsCubit')); + } + + AuthenticationState get authentication { + return AuthenticationState.fromJson(read('AuthenticationCubit')); + } +} diff --git a/lib/features/document_details/bloc/document_details_cubit.dart b/lib/features/document_details/bloc/document_details_cubit.dart index 028893a..b7a00cb 100644 --- a/lib/features/document_details/bloc/document_details_cubit.dart +++ b/lib/features/document_details/bloc/document_details_cubit.dart @@ -8,22 +8,40 @@ class DocumentDetailsCubit extends Cubit { final PaperlessDocumentsApi _api; DocumentDetailsCubit(this._api, DocumentModel initialDocument) - : super(DocumentDetailsState(document: initialDocument)); + : super(DocumentDetailsState(document: initialDocument)) { + loadSuggestions(); + } Future delete(DocumentModel document) async { await _api.delete(document); } + Future loadSuggestions() async { + final suggestions = await _api.findSuggestions(state.document); + emit(state.copyWith(suggestions: suggestions)); + } + + Future loadFullContent() async { + final doc = await _api.find(state.document.id); + if (doc == null) { + return; + } + emit(state.copyWith( + isFullContentLoaded: true, + fullContent: doc.content, + )); + } + Future assignAsn(DocumentModel document) async { if (document.archiveSerialNumber == null) { final int asn = await _api.findNextAsn(); final updatedDocument = await _api.update(document.copyWith(archiveSerialNumber: asn)); - emit(DocumentDetailsState(document: updatedDocument)); + emit(state.copyWith(document: updatedDocument)); } } void replaceDocument(DocumentModel document) { - emit(DocumentDetailsState(document: document)); + emit(state.copyWith(document: document)); } } diff --git a/lib/features/document_details/bloc/document_details_state.dart b/lib/features/document_details/bloc/document_details_state.dart index 81d2bfd..29a2359 100644 --- a/lib/features/document_details/bloc/document_details_state.dart +++ b/lib/features/document_details/bloc/document_details_state.dart @@ -2,11 +2,36 @@ part of 'document_details_cubit.dart'; class DocumentDetailsState with EquatableMixin { final DocumentModel document; + final bool isFullContentLoaded; + final String? fullContent; + final FieldSuggestions suggestions; const DocumentDetailsState({ required this.document, + this.suggestions = const FieldSuggestions(), + this.isFullContentLoaded = false, + this.fullContent, }); @override - List get props => [document]; + List get props => [ + document, + suggestions, + isFullContentLoaded, + fullContent, + ]; + + DocumentDetailsState copyWith({ + DocumentModel? document, + FieldSuggestions? suggestions, + bool? isFullContentLoaded, + String? fullContent, + }) { + return DocumentDetailsState( + document: document ?? this.document, + suggestions: suggestions ?? this.suggestions, + isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded, + fullContent: fullContent ?? this.fullContent, + ); + } } 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 f29a83d..59c35b9 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -25,6 +25,7 @@ import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:badges/badges.dart' as b; class DocumentDetailsPage extends StatefulWidget { final bool allowEdit; @@ -63,9 +64,21 @@ class _DocumentDetailsPageState extends State { if (!connectivityState.isConnected) { return Container(); } - return FloatingActionButton( - child: const Icon(Icons.edit), - onPressed: () => _onEdit(state.document), + return b.Badge( + position: b.BadgePosition.topEnd(top: -12, end: -6), + showBadge: state.suggestions.hasSuggestions, + child: FloatingActionButton( + child: const Icon(Icons.edit), + onPressed: () => _onEdit(state.document), + ), + badgeContent: Text( + '${state.suggestions.suggestionsCount}', + style: const TextStyle( + color: Colors.white, + ), + ), + badgeColor: Theme.of(context).colorScheme.error, + //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd ); }, ); @@ -182,6 +195,7 @@ class _DocumentDetailsPageState extends State { _buildDocumentContentView( state.document, widget.titleAndContentQueryString, + state, ), _buildDocumentMetaDataView( state.document, @@ -217,7 +231,9 @@ class _DocumentDetailsPageState extends State { listener: (context, state) { cubit.replaceDocument(state.document); }, - child: const DocumentEditPage(), + child: DocumentEditPage( + suggestions: cubit.state.suggestions, + ), ), ), maintainState: true, @@ -303,14 +319,30 @@ class _DocumentDetailsPageState extends State { } } - Widget _buildDocumentContentView(DocumentModel document, String? match) { - return SingleChildScrollView( - child: HighlightedText( - text: document.content ?? "", - highlights: match == null ? [] : match.split(" "), - style: Theme.of(context).textTheme.bodyMedium, - caseSensitive: false, - ), + Widget _buildDocumentContentView( + DocumentModel document, + String? match, + DocumentDetailsState state, + ) { + return ListView( + children: [ + HighlightedText( + text: (state.isFullContentLoaded + ? state.fullContent + : document.content) ?? + "", + highlights: match == null ? [] : match.split(" "), + style: Theme.of(context).textTheme.bodyMedium, + caseSensitive: false, + ), + if (!state.isFullContentLoaded && (document.content ?? '').isNotEmpty) + TextButton( + child: Text("Show full content ..."), + onPressed: () { + context.read().loadFullContent(); + }, + ), + ], ).paddedOnly(top: 8); } diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index 47c1b8a..316b14a 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -55,17 +55,16 @@ class DocumentUploadCubit extends Cubit { )); } - Future upload( + Future upload( Uint8List bytes, { required String filename, required String title, - required void Function(DocumentModel document)? onConsumptionFinished, int? documentType, int? correspondent, Iterable tags = const [], DateTime? createdAt, }) async { - await _documentApi.create( + return await _documentApi.create( bytes, filename: filename, title: title, @@ -74,11 +73,6 @@ class DocumentUploadCubit extends Cubit { tags: tags, createdAt: createdAt, ); - if (onConsumptionFinished != null) { - _documentApi - .waitForConsumptionFinished(filename, title) - .then((value) => onConsumptionFinished(value)); - } } @override diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 38ae0ad..4fc8769 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -25,14 +25,12 @@ class DocumentUploadPreparationPage extends StatefulWidget { final String? title; final String? filename; final String? fileExtension; - final void Function(DocumentModel)? onSuccessfullyConsumed; const DocumentUploadPreparationPage({ Key? key, required this.fileBytes, this.title, this.filename, - this.onSuccessfullyConsumed, this.fileExtension, }) : super(key: key); @@ -236,19 +234,18 @@ class _DocumentUploadPreparationPageState final correspondent = fv[DocumentModel.correspondentKey] as IdQueryParameter; - await cubit.upload( + final taskId = await cubit.upload( widget.fileBytes, filename: _padWithPdfExtension(_formKey.currentState?.value[fkFileName]), title: title, - onConsumptionFinished: widget.onSuccessfullyConsumed, documentType: docType.id, correspondent: correspondent.id, tags: tags.ids, createdAt: createdAt, ); showSnackBar(context, S.of(context).documentUploadSuccessText); - Navigator.pop(context, true); + Navigator.pop(context, taskId); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } on PaperlessValidationErrors catch (errors) { diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart index b5846c2..6b489b3 100644 --- a/lib/features/documents/bloc/documents_cubit.dart +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -52,7 +52,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { log("[DocumentsCubit] load"); emit(state.copyWith(isLoading: true)); try { - final result = await _api.find(state.filter); + final result = await _api.findAll(state.filter); emit(state.copyWith( isLoading: false, hasLoaded: true, @@ -67,11 +67,13 @@ class DocumentsCubit extends Cubit with HydratedMixin { log("[DocumentsCubit] reload"); emit(state.copyWith(isLoading: true)); try { - final result = await _api.find(state.filter.copyWith(page: 1)); + final filter = state.filter.copyWith(page: 1); + final result = await _api.findAll(filter); emit(state.copyWith( hasLoaded: true, value: [result], isLoading: false, + filter: filter, )); } finally { emit(state.copyWith(isLoading: false)); @@ -81,7 +83,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { Future _bulkReloadDocuments() async { emit(state.copyWith(isLoading: true)); try { - final result = await _api.find( + final result = await _api.findAll( state.filter.copyWith( page: 1, pageSize: state.documents.length, @@ -106,7 +108,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { emit(state.copyWith(isLoading: true)); final newFilter = state.filter.copyWith(page: state.filter.page + 1); try { - final result = await _api.find(newFilter); + final result = await _api.findAll(newFilter); emit( DocumentsState( hasLoaded: true, @@ -129,7 +131,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { log("[DocumentsCubit] updateFilter"); try { emit(state.copyWith(isLoading: true)); - final result = await _api.find(filter.copyWith(page: 1)); + final result = await _api.findAll(filter.copyWith(page: 1)); emit( DocumentsState( @@ -163,7 +165,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { void toggleDocumentSelection(DocumentModel model) { log("[DocumentsCubit] toggleSelection"); - if (state.selection.contains(model)) { + if (state.selectedIds.contains(model.id)) { emit( state.copyWith( selection: state.selection @@ -183,6 +185,12 @@ class DocumentsCubit extends Cubit with HydratedMixin { emit(state.copyWith(selection: [])); } + @override + void onChange(Change change) { + super.onChange(change); + log("[DocumentsCubit] state changed: ${change.currentState.selection.map((e) => e.id).join(",")} to ${change.nextState.selection.map((e) => e.id).join(",")}"); + } + void reset() { log("[DocumentsCubit] reset"); emit(const DocumentsState()); @@ -196,7 +204,7 @@ class DocumentsCubit extends Cubit with HydratedMixin { if (filter == null) { return; } - final results = await _api.find(filter.copyWith(page: 1)); + final results = await _api.findAll(filter.copyWith(page: 1)); emit( DocumentsState( filter: filter, diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart index 9abfb69..202cfa0 100644 --- a/lib/features/documents/bloc/documents_state.dart +++ b/lib/features/documents/bloc/documents_state.dart @@ -23,6 +23,8 @@ class DocumentsState extends Equatable { this.selectedSavedViewId, }); + List get selectedIds => selection.map((e) => e.id).toList(); + int get currentPageNumber { return filter.page; } diff --git a/lib/features/documents/view/pages/document_edit_page.dart b/lib/features/documents/view/pages/document_edit_page.dart index 22ad59d..823a921 100644 --- a/lib/features/documents/view/pages/document_edit_page.dart +++ b/lib/features/documents/view/pages/document_edit_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -15,14 +16,18 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart'; +import 'package:paperless_mobile/features/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 'package:paperless_mobile/util.dart'; class DocumentEditPage extends StatefulWidget { + final FieldSuggestions suggestions; const DocumentEditPage({ Key? key, + required this.suggestions, }) : super(key: key); @override @@ -74,14 +79,17 @@ class _DocumentEditPageState extends State { _buildTitleFormField(state.document.title).padded(), _buildCreatedAtFormField(state.document.created).padded(), _buildDocumentTypeFormField( - state.document.documentType, state.documentTypes) - .padded(), + state.document.documentType, + state.documentTypes, + ).padded(), _buildCorrespondentFormField( - state.document.correspondent, state.correspondents) - .padded(), + state.document.correspondent, + state.correspondents, + ).padded(), _buildStoragePathFormField( - state.document.storagePath, state.storagePaths) - .padded(), + state.document.storagePath, + state.storagePaths, + ).padded(), TagFormField( initialValue: IdsTagsQuery.included(state.document.tags.toList()), @@ -99,58 +107,101 @@ class _DocumentEditPageState extends State { } Widget _buildStoragePathFormField( - int? initialId, Map options) { - return LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( - create: (context) => context - .read>(), - child: AddStoragePathPage(initalValue: initialValue), - ), - textFieldLabel: S.of(context).documentStoragePathPropertyLabel, - labelOptions: options, - initialValue: IdQueryParameter.fromId(initialId), - name: fkStoragePath, - prefixIcon: const Icon(Icons.folder_outlined), + int? initialId, + Map options, + ) { + return Column( + children: [ + LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( + create: (context) => context.read< + LabelRepository>(), + child: AddStoragePathPage(initalValue: initialValue), + ), + textFieldLabel: S.of(context).documentStoragePathPropertyLabel, + labelOptions: options, + initialValue: IdQueryParameter.fromId(initialId), + name: fkStoragePath, + prefixIcon: const Icon(Icons.folder_outlined), + ), + if (widget.suggestions.hasSuggestedStoragePaths) + _buildSuggestionsSkeleton( + suggestions: widget.suggestions.storagePaths, + itemBuilder: (context, itemData) => ActionChip( + label: Text(options[itemData]!.name), + onPressed: () => _formKey.currentState?.fields[fkStoragePath] + ?.didChange((IdQueryParameter.fromId(itemData))), + ), + ), + ], ); } Widget _buildCorrespondentFormField( int? initialId, Map options) { - return LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( - create: (context) => context.read< - LabelRepository>(), - child: AddCorrespondentPage(initialName: initialValue), - ), - textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, - labelOptions: options, - initialValue: IdQueryParameter.fromId(initialId), - name: fkCorrespondent, - prefixIcon: const Icon(Icons.person_outlined), + return Column( + children: [ + LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( + create: (context) => context.read< + LabelRepository>(), + child: AddCorrespondentPage(initialName: initialValue), + ), + textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, + labelOptions: options, + initialValue: IdQueryParameter.fromId(initialId), + name: fkCorrespondent, + prefixIcon: const Icon(Icons.person_outlined), + ), + if (widget.suggestions.hasSuggestedCorrespondents) + _buildSuggestionsSkeleton( + suggestions: widget.suggestions.correspondents, + itemBuilder: (context, itemData) => ActionChip( + label: Text(options[itemData]!.name), + onPressed: () => _formKey.currentState?.fields[fkCorrespondent] + ?.didChange((IdQueryParameter.fromId(itemData))), + ), + ), + ], ); } Widget _buildDocumentTypeFormField( - int? initialId, Map options) { - return LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (currentInput) => RepositoryProvider( - create: (context) => context - .read>(), - child: AddDocumentTypePage( - initialName: currentInput, + int? initialId, + Map options, + ) { + return Column( + children: [ + LabelFormField( + notAssignedSelectable: false, + formBuilderState: _formKey.currentState, + labelCreationWidgetBuilder: (currentInput) => RepositoryProvider( + create: (context) => context.read< + LabelRepository>(), + child: AddDocumentTypePage( + initialName: currentInput, + ), + ), + textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, + initialValue: IdQueryParameter.fromId(initialId), + labelOptions: options, + name: fkDocumentType, + prefixIcon: const Icon(Icons.description_outlined), ), - ), - textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, - initialValue: IdQueryParameter.fromId(initialId), - labelOptions: options, - name: fkDocumentType, - prefixIcon: const Icon(Icons.description_outlined), + if (widget.suggestions.hasSuggestedDocumentTypes) + _buildSuggestionsSkeleton( + suggestions: widget.suggestions.documentTypes, + itemBuilder: (context, itemData) => ActionChip( + label: Text(options[itemData]!.name), + onPressed: () => _formKey.currentState?.fields[fkDocumentType] + ?.didChange(IdQueryParameter.fromId(itemData)), + ), + ), + ], ); } @@ -198,16 +249,56 @@ class _DocumentEditPageState extends State { } Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) { - return FormBuilderDateTimePicker( - inputType: InputType.date, - name: fkCreatedDate, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.calendar_month_outlined), - label: Text(S.of(context).documentCreatedPropertyLabel), - ), - initialValue: initialCreatedAtDate, - format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format - initialEntryMode: DatePickerEntryMode.calendar, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FormBuilderDateTimePicker( + inputType: InputType.date, + name: fkCreatedDate, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.calendar_month_outlined), + label: Text(S.of(context).documentCreatedPropertyLabel), + ), + initialValue: initialCreatedAtDate, + format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format + initialEntryMode: DatePickerEntryMode.calendar, + ), + if (widget.suggestions.hasSuggestedDates) + _buildSuggestionsSkeleton( + suggestions: widget.suggestions.dates, + itemBuilder: (context, itemData) => ActionChip( + label: Text(DateFormat.yMd().format(itemData)), + onPressed: () => _formKey.currentState?.fields[fkCreatedDate] + ?.didChange(itemData), + ), + ), + ], ); } + + Widget _buildSuggestionsSkeleton({ + required Iterable suggestions, + required ItemBuilder itemBuilder, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Suggestions: ", + style: Theme.of(context).textTheme.bodySmall, + ), + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: suggestions.length, + itemBuilder: (context, index) => + itemBuilder(context, suggestions.elementAt(index)), + separatorBuilder: (BuildContext context, int index) => + const SizedBox(width: 4.0), + ), + ), + ], + ).padded(); + } } diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 6a0b69c..2f7200c 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,27 +1,30 @@ +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:infinite_scroll_pagination/infinite_scroll_pagination.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/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.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/documents_page_app_bar.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; import 'package:paperless_mobile/features/home/view/widget/info_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/settings/bloc/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/model/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'; class DocumentFilterIntent { @@ -42,9 +45,11 @@ class DocumentsPage extends StatefulWidget { } class _DocumentsPageState extends State { - final _pagingController = PagingController( - firstPageKey: 1, - ); + final ScrollController _scrollController = ScrollController(); + double _offset = 0; + double _last = 0; + + static const double _savedViewWidgetHeight = 78 + 16; @override void initState() { @@ -55,12 +60,36 @@ class _DocumentsPageState extends State { } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } - _pagingController.addPageRequestListener(_loadNewPage); + _scrollController + ..addListener(_listenForScrollChanges) + ..addListener(_listenForLoadDataTrigger); + } + + void _listenForLoadDataTrigger() { + final currState = context.read().state; + if (_scrollController.offset >= + _scrollController.position.maxScrollExtent && + !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(() {}); + } } @override void dispose() { - _pagingController.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -78,6 +107,7 @@ class _DocumentsPageState extends State { } }, builder: (context, connectivityState) { + const linearProgressIndicatorHeight = 4.0; return Scaffold( drawer: BlocProvider.value( value: context.read(), @@ -85,16 +115,65 @@ class _DocumentsPageState extends State { afterInboxClosed: () => context.read().reload(), ), ), + appBar: PreferredSize( + preferredSize: const Size.fromHeight( + kToolbarHeight + linearProgressIndicatorHeight, + ), + child: BlocBuilder( + builder: (context, state) { + 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 + ? const LinearProgressIndicator() + : const SizedBox(height: 4.0), + ), + ); + }, + ), + ), floatingActionButton: BlocBuilder( builder: (context, state) { final appliedFiltersCount = state.filter.appliedFiltersCount; - return Badge.count( - //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd - alignment: const AlignmentDirectional(44, -4), - isLabelVisible: appliedFiltersCount > 0, - count: state.filter.appliedFiltersCount, - backgroundColor: Colors.red, - textColor: Colors.white, + return b.Badge( + position: b.BadgePosition.topEnd(top: -12, end: -6), + showBadge: appliedFiltersCount > 0, + badgeContent: Text( + '$appliedFiltersCount', + style: const TextStyle( + color: Colors.white, + ), + ), + animationType: b.BadgeAnimationType.fade, + badgeColor: Theme.of(context).colorScheme.error, child: FloatingActionButton( child: const Icon(Icons.filter_alt_outlined), onPressed: _openDocumentFilter, @@ -103,12 +182,71 @@ class _DocumentsPageState extends State { }, ), resizeToAvoidBottomInset: true, - body: _buildBody(connectivityState), + body: WillPopScope( + onWillPop: () async { + if (context.read().state.selection.isNotEmpty) { + context.read().resetSelection(); + } + 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, + ), + ); + }, + ), + ), + if (taskState.task != null && + taskState.isSuccess && + !taskState.task!.acknowledged) + _buildNewDocumentAvailableButton(context), + ], + ); + }, + ), + ), + ), ); }, ); } + Align _buildNewDocumentAvailableButton(BuildContext context) { + return Align( + alignment: Alignment.bottomLeft, + child: FilledButton( + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Theme.of(context).colorScheme.error), + ), + child: Text("New document available!"), + onPressed: () { + context.read().acknowledgeCurrentTask(); + context.read().reload(); + }, + ).paddedOnly(bottom: 24, left: 24), + ); + } + void _openDocumentFilter() async { final draggableSheetController = DraggableScrollableController(); final filterIntent = await showModalBottomSheet( @@ -160,88 +298,45 @@ 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), + 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 - _pagingController.value = PagingState( - itemList: state.documents, - nextPageKey: state.nextPageNumber, - ); - - late Widget child; - switch (settings.preferredViewType) { - case ViewType.list: - child = DocumentListView( - state: state, - onTap: _openDetails, - onSelected: _onSelected, - pagingController: _pagingController, - hasInternetConnection: isConnected, - onTagSelected: _addTagToFilter, - onCorrespondentSelected: _addCorrespondentToFilter, - onDocumentTypeSelected: _addDocumentTypeToFilter, - onStoragePathSelected: _addStoragePathToFilter, - ); - break; - case ViewType.grid: - child = DocumentGridView( - state: state, - onTap: _openDetails, - onSelected: _onSelected, - pagingController: _pagingController, - hasInternetConnection: isConnected, - onTagSelected: _addTagToFilter, - onCorrespondentSelected: _addCorrespondentToFilter, - onDocumentTypeSelected: _addDocumentTypeToFilter, - onStoragePathSelected: _addStoragePathToFilter, - ); - break; - } if (state.hasLoaded && state.documents.isEmpty) { - child = SliverToBoxAdapter( - child: DocumentsEmptyState( - state: state, - onReset: () { - context.read().resetFilter(); - context.read().unselectView(); - }, - ), + return DocumentsEmptyState( + state: state, + onReset: () { + context.read().resetFilter(); + context.read().unselectView(); + }, ); } - return RefreshIndicator( - onRefresh: _onRefresh, - notificationPredicate: (_) => isConnected, - child: CustomScrollView( - slivers: [ - DocumentsPageAppBar( - isOffline: connectivityState != ConnectivityState.connected, - actions: [ - const SortDocumentsButton(), - IconButton( - icon: Icon( - settings.preferredViewType == ViewType.grid - ? Icons.list - : Icons.grid_view, - ), - onPressed: () => context - .read() - .setViewType( - settings.preferredViewType.toggle(), - ), - ), - ], - ), - child, - ], - ), + 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), ); }, ); @@ -355,15 +450,9 @@ class _DocumentsPageState extends State { } } - Future _loadNewPage(int pageKey) async { - final documentsCubit = context.read(); - final pageCount = documentsCubit.state - .inferPageCount(pageSize: documentsCubit.state.filter.pageSize); - if (pageCount <= pageKey + 1) { - _pagingController.nextPageKey = null; - } + Future _loadNewPage() async { try { - await documentsCubit.loadMore(); + await context.read().loadMore(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } @@ -376,8 +465,8 @@ class _DocumentsPageState extends State { Future _onRefresh() async { try { // We do not await here on purpose so we can show a linear progress indicator below the app bar. - await context.read().reload(); - await context.read().reload(); + context.read().reload(); + context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } diff --git a/lib/features/documents/view/widgets/grid/document_grid.dart b/lib/features/documents/view/widgets/grid/document_grid.dart deleted file mode 100644 index 5a9c23f..0000000 --- a/lib/features/documents/view/widgets/grid/document_grid.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.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/grid/document_grid_item.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -class DocumentGridView extends StatelessWidget { - final void Function(DocumentModel model) onTap; - final void Function(DocumentModel) onSelected; - final PagingController pagingController; - final DocumentsState state; - final bool hasInternetConnection; - final void Function(int tagId) onTagSelected; - final void Function(int correspondentId) onCorrespondentSelected; - final void Function(int correspondentId) onDocumentTypeSelected; - final void Function(int? id)? onStoragePathSelected; - - const DocumentGridView({ - super.key, - required this.onTap, - required this.pagingController, - required this.state, - required this.onSelected, - required this.hasInternetConnection, - required this.onTagSelected, - required this.onCorrespondentSelected, - required this.onDocumentTypeSelected, - this.onStoragePathSelected, - }); - @override - Widget build(BuildContext context) { - return PagedSliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - childAspectRatio: 1 / 2, - ), - pagingController: pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return DocumentGridItem( - document: item, - onTap: onTap, - isSelected: state.selection.contains(item), - 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, - ); - }, - noItemsFoundIndicatorBuilder: (context) => - const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget - ), - ); - } -} diff --git a/lib/features/documents/view/widgets/grid/document_grid_item.dart b/lib/features/documents/view/widgets/grid/document_grid_item.dart index 6a11081..d460444 100644 --- a/lib/features/documents/view/widgets/grid/document_grid_item.dart +++ b/lib/features/documents/view/widgets/grid/document_grid_item.dart @@ -13,7 +13,7 @@ class DocumentGridItem extends StatelessWidget { final void Function(DocumentModel) onSelected; final bool isAtLeastOneSelected; final bool Function(int tagId) isTagSelectedPredicate; - final void Function(int tagId) onTagSelected; + final void Function(int tagId)? onTagSelected; const DocumentGridItem({ Key? key, @@ -57,9 +57,11 @@ class DocumentGridItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CorrespondentWidget( - correspondentId: document.correspondent), + correspondentId: document.correspondent, + ), DocumentTypeWidget( - documentTypeId: document.documentType), + documentTypeId: document.documentType, + ), Text( document.title, maxLines: document.tags.isEmpty ? 3 : 2, diff --git a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart new file mode 100644 index 0000000..d5fe813 --- /dev/null +++ b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart @@ -0,0 +1,118 @@ +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, + 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 SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 178, + 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.dart b/lib/features/documents/view/widgets/list/document_list.dart deleted file mode 100644 index a28cb97..0000000 --- a/lib/features/documents/view/widgets/list/document_list.dart +++ /dev/null @@ -1,73 +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/core/widgets/documents_list_loading_widget.dart'; -import 'package:paperless_mobile/core/widgets/offline_widget.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/list/document_list_item.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -class DocumentListView extends StatelessWidget { - final void Function(DocumentModel) onTap; - final void Function(DocumentModel) onSelected; - - final PagingController pagingController; - 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; - - const DocumentListView({ - super.key, - required this.onTap, - required this.pagingController, - required this.state, - required this.onSelected, - required this.hasInternetConnection, - this.isLabelClickable = true, - this.onTagSelected, - this.onCorrespondentSelected, - this.onDocumentTypeSelected, - this.onStoragePathSelected, - }); - - @override - Widget build(BuildContext context) { - return PagedSliverList( - pagingController: pagingController, - builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - itemBuilder: (context, document, index) { - return LabelRepositoriesProvider( - child: DocumentListItem( - isLabelClickable: isLabelClickable, - document: document, - onTap: onTap, - isSelected: state.selection.contains(document), - 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, - ), - ); - }, - noItemsFoundIndicatorBuilder: (context) => hasInternetConnection - ? const DocumentsListLoadingWidget() - : const OfflineWidget(), - ), - ); - } -} diff --git a/lib/features/documents/view/widgets/list/document_list_item.dart b/lib/features/documents/view/widgets/list/document_list_item.dart index e73c97b..bec41fd 100644 --- a/lib/features/documents/view/widgets/list/document_list_item.dart +++ b/lib/features/documents/view/widgets/list/document_list_item.dart @@ -38,6 +38,7 @@ class DocumentListItem extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( child: ListTile( + trailing: Text("${document.id}"), dense: true, selected: isSelected, onTap: () => _onTap(), diff --git a/lib/features/documents/view/widgets/new_items_loading_widget.dart b/lib/features/documents/view/widgets/new_items_loading_widget.dart new file mode 100644 index 0000000..dfe4553 --- /dev/null +++ b/lib/features/documents/view/widgets/new_items_loading_widget.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class NewItemsLoadingWidget extends StatelessWidget { + const NewItemsLoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return const CircularProgressIndicator(); + } +} 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 index 0ee35de..4303a73 100644 --- a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart @@ -138,3 +138,19 @@ class _DocumentsPageAppBarState extends State { 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/home/view/home_page.dart b/lib/features/home/view/home_page.dart index ee67956..e4d9af2 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -47,46 +47,6 @@ class _HomePageState extends State { @override void initState() { super.initState(); - LocalNotificationService.instance.notifyTaskChanged( - Task( - id: 100, - dateCreated: DateTime.now(), - dateDone: DateTime.now(), - taskFileName: "test_file.pdf", - status: TaskStatus.started, - taskId: "abc-def-123-456", - type: "file", - ), - ); - Future.delayed(const Duration(seconds: 5), () { - LocalNotificationService.instance.notifyTaskChanged( - Task( - id: 100, - dateCreated: DateTime.now(), - dateDone: DateTime.now(), - taskFileName: "test_file.pdf", - status: TaskStatus.pending, - taskId: "abc-def-123-456", - type: "file", - ), - ); - }); - Future.delayed(const Duration(seconds: 10), () { - LocalNotificationService.instance.notifyTaskChanged( - Task( - id: 100, - acknowledged: false, - dateCreated: DateTime.now(), - dateDone: DateTime.now(), - relatedDocumentId: 180, - result: "New document successfully created.", - status: TaskStatus.success, - taskFileName: "test_file.pdf", - taskId: "abc-def-123-456", - type: "file", - ), - ); - }); _initializeData(context); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _listenForReceivedFiles(); @@ -195,7 +155,14 @@ class _HomePageState extends State { }, ), BlocListener( - listener: (context, state) {}, + listener: (context, state) { + if (state.task != null) { + // Handle local notifications on task change (only when app is running for now). + context + .read() + .notifyTaskChanged(state.task!); + } + }, ), ], child: Scaffold( diff --git a/lib/features/inbox/bloc/inbox_cubit.dart b/lib/features/inbox/bloc/inbox_cubit.dart index 4e8b5f0..f1b507a 100644 --- a/lib/features/inbox/bloc/inbox_cubit.dart +++ b/lib/features/inbox/bloc/inbox_cubit.dart @@ -1,5 +1,4 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:injectable/injectable.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/tag_repository_state.dart'; @@ -28,7 +27,7 @@ class InboxCubit extends Cubit { )); } final inboxDocuments = await _documentsApi - .find(DocumentFilter( + .findAll(DocumentFilter( tags: AnyAssignedTagsQuery(tagIds: inboxTags), sortField: SortField.added, )) diff --git a/lib/features/labels/tags/view/widgets/tags_widget.dart b/lib/features/labels/tags/view/widgets/tags_widget.dart index cc7d4bc..2fafb0b 100644 --- a/lib/features/labels/tags/view/widgets/tags_widget.dart +++ b/lib/features/labels/tags/view/widgets/tags_widget.dart @@ -10,7 +10,7 @@ class TagsWidget extends StatefulWidget { final Iterable tagIds; final bool isMultiLine; final VoidCallback? afterTagTapped; - final void Function(int tagId) onTagSelected; + final void Function(int tagId)? onTagSelected; final bool isClickable; final bool Function(int id) isSelectedPredicate; @@ -42,7 +42,7 @@ class _TagsWidgetState extends State { afterTagTapped: widget.afterTagTapped, isClickable: widget.isClickable, isSelected: widget.isSelectedPredicate(id), - onSelected: () => widget.onTagSelected(id), + onSelected: () => widget.onTagSelected?.call(id), ), ) .toList(); diff --git a/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart b/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart index 23064d2..cc1fd3b 100644 --- a/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart +++ b/lib/features/linked_documents_preview/bloc/linked_documents_cubit.dart @@ -11,7 +11,7 @@ class LinkedDocumentsCubit extends Cubit { } Future _initialize() async { - final documents = await _api.find( + final documents = await _api.findAll( state.filter.copyWith( pageSize: 100, ), 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 index f4ea7eb..7c6f36e 100644 --- a/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart +++ b/lib/features/linked_documents_preview/view/pages/linked_documents_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.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'; @@ -18,15 +17,6 @@ class LinkedDocumentsPage extends StatefulWidget { } class _LinkedDocumentsPageState extends State { - final _pagingController = - PagingController(firstPageKey: 1); - - @override - void initState() { - super.initState(); - _pagingController.nextPageKey = null; - } - @override Widget build(BuildContext context) { return Scaffold( @@ -38,8 +28,6 @@ class _LinkedDocumentsPageState extends State { if (!state.isLoaded) { return const DocumentsListLoadingWidget(); } - - _pagingController.itemList = state.documents!.results; return Column( children: [ Text( @@ -48,42 +36,34 @@ class _LinkedDocumentsPageState extends State { style: Theme.of(context).textTheme.bodySmall, ), Expanded( - child: CustomScrollView( - slivers: [ - PagedSliverList( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - itemBuilder: (context, document, index) { - return DocumentListItem( - isLabelClickable: false, - document: document, - onTap: (doc) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - document, - ), - child: const DocumentDetailsPage( - isLabelClickable: false, - allowEdit: false, - ), - ), - ), - ); - }, - isSelected: false, - isAtLeastOneSelected: false, - isTagSelectedPredicate: (_) => false, - onTagSelected: (int tag) {}, - ); - }, - ), - ), - ], + child: ListView.builder( + 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/bloc/authentication_cubit.dart b/lib/features/login/bloc/authentication_cubit.dart index bd7a20a..a907638 100644 --- a/lib/features/login/bloc/authentication_cubit.dart +++ b/lib/features/login/bloc/authentication_cubit.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.dart'; +import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_state.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; @@ -12,7 +12,7 @@ class AuthenticationCubit extends Cubit with HydratedMixin { final LocalAuthenticationService _localAuthService; final PaperlessAuthenticationApi _authApi; - final AuthenticationAwareDioManager _dioWrapper; + final SessionManager _dioWrapper; AuthenticationCubit( this._localAuthService, diff --git a/lib/features/login/services/authentication_service.dart b/lib/features/login/services/authentication_service.dart index efb02a3..84aa8c4 100644 --- a/lib/features/login/services/authentication_service.dart +++ b/lib/features/login/services/authentication_service.dart @@ -1,8 +1,6 @@ -import 'package:injectable/injectable.dart'; import 'package:local_auth/local_auth.dart'; import 'package:paperless_mobile/core/store/local_vault.dart'; -@lazySingleton class LocalAuthenticationService { final LocalVault localStore; final LocalAuthentication localAuthentication; diff --git a/lib/features/notifications/cubit/notification_cubit.dart b/lib/features/notifications/cubit/notification_cubit.dart deleted file mode 100644 index 458db3d..0000000 --- a/lib/features/notifications/cubit/notification_cubit.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -part 'notification_state.dart'; - -class NotificationCubit extends Cubit { - NotificationCubit() : super(NotificationInitialState()); - - void navigateTo(String route, dynamic args) {} -} diff --git a/lib/features/notifications/cubit/notification_state.dart b/lib/features/notifications/cubit/notification_state.dart deleted file mode 100644 index a3d64c9..0000000 --- a/lib/features/notifications/cubit/notification_state.dart +++ /dev/null @@ -1,16 +0,0 @@ -part of 'notification_cubit.dart'; - -abstract class NotificationState extends Equatable { - const NotificationState(); - - @override - List get props => []; -} - -class NotificationInitialState extends NotificationState {} - -class NotificationOpenDocumentDetailsPageState extends NotificationState { - final int documentId; - - const NotificationOpenDocumentDetailsPageState(this.documentId); -} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index ab52749..753ab66 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -11,9 +11,7 @@ class LocalNotificationService { final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); - LocalNotificationService._(); - - static final LocalNotificationService instance = LocalNotificationService._(); + LocalNotificationService(); Future initialize() async { const AndroidInitializationSettings initializationSettingsAndroid = @@ -71,7 +69,7 @@ class LocalNotificationService { body = task.taskFileName; timestampMillis = task.dateDone!.millisecondsSinceEpoch; payload = CreateDocumentSuccessNotificationResponsePayload( - task.relatedDocumentId!, + task.relatedDocument!, ); break; default: 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 9119c29..29da7b6 100644 --- a/lib/features/saved_view/view/saved_view_selection_widget.dart +++ b/lib/features/saved_view/view/saved_view_selection_widget.dart @@ -4,6 +4,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/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'; @@ -31,91 +32,97 @@ class SavedViewSelectionWidget extends StatelessWidget { return BlocBuilder( builder: (context, connectivityState) { final hasInternetConnection = connectivityState.isConnected; - return Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, state) { - if (!state.hasLoaded) { - return _buildLoadingWidget(context); - } - if (state.value.isEmpty) { - return Text(S.of(context).savedViewsEmptyStateText); - } - return SizedBox( - height: height, - 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) { - return FilterChip( - label: Text( - state.value.values.toList()[index].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) + 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, - label: Text(S.of(context).savedViewCreateNewLabel), + 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) { - final r = Random(123456789); return SizedBox( - height: height, - width: double.infinity, + height: 38, + width: MediaQuery.of(context).size.width, child: Shimmer.fromColors( baseColor: Theme.of(context).brightness == Brightness.light ? Colors.grey[300]! @@ -123,14 +130,35 @@ class SavedViewSelectionWidget extends StatelessWidget { highlightColor: Theme.of(context).brightness == Brightness.light ? Colors.grey[100]! : Colors.grey[600]!, - child: ListView.separated( + child: ListView( scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), - itemCount: 10, - itemBuilder: (context, index) => FilterChip( - label: SizedBox(width: r.nextInt((index * 20) + 50).toDouble()), - onSelected: null), - separatorBuilder: (context, index) => const SizedBox(width: 4.0), + 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: (_) {}, + ), + ], ), ), ); diff --git a/lib/features/scan/view/scanner_page.dart b/lib/features/scan/view/scanner_page.dart index ce81fec..d287f02 100644 --- a/lib/features/scan/view/scanner_page.dart +++ b/lib/features/scan/view/scanner_page.dart @@ -24,6 +24,7 @@ import 'package:paperless_mobile/features/documents/view/pages/document_view.dar import 'package:paperless_mobile/features/home/view/widget/info_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/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; import 'package:path/path.dart' as p; @@ -140,7 +141,7 @@ class _ScannerPageState extends State final file = await _assembleFileBytes( context.read().state, ); - final uploaded = await Navigator.of(context).push( + final taskId = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => LabelRepositoriesProvider( child: BlocProvider( @@ -165,8 +166,10 @@ class _ScannerPageState extends State ), ) ?? false; - if (uploaded) { + if (taskId != null) { + // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); + context.read().listenToTaskChanges(taskId); } } diff --git a/lib/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart index 0bad389..66d0a87 100644 --- a/lib/features/tasks/cubit/task_status_cubit.dart +++ b/lib/features/tasks/cubit/task_status_cubit.dart @@ -7,20 +7,30 @@ class TaskStatusCubit extends Cubit { final PaperlessTasksApi _api; TaskStatusCubit(this._api) : super(const TaskStatusState()); - void startListeningToTask(String taskId) { + void listenToTaskChanges(String taskId) { _api .listenForTaskChanges(taskId) .forEach( - (element) => TaskStatusState( - isListening: true, - isAcknowledged: false, - task: element, + (element) => emit( + TaskStatusState( + isListening: true, + task: element, + ), ), ) .whenComplete(() => emit(state.copyWith(isListening: false))); } - void acknowledgeCurrentTask() { - emit(state.copyWith(isListening: false, isAcknowledged: true)); + Future acknowledgeCurrentTask() async { + if (state.task == null) { + return; + } + final task = await _api.acknowledgeTask(state.task!); + emit( + state.copyWith( + task: task, + isListening: false, + ), + ); } } diff --git a/lib/features/tasks/cubit/task_status_state.dart b/lib/features/tasks/cubit/task_status_state.dart index 6e69901..163d3db 100644 --- a/lib/features/tasks/cubit/task_status_state.dart +++ b/lib/features/tasks/cubit/task_status_state.dart @@ -3,22 +3,20 @@ part of 'task_status_cubit.dart'; class TaskStatusState extends Equatable { final Task? task; final bool isListening; - final bool isAcknowledged; const TaskStatusState({ this.task, this.isListening = false, - this.isAcknowledged = false, }); - bool get isActive => isListening && !isAcknowledged; - bool get isSuccess => task?.status == TaskStatus.success; + bool get isAcknowledged => task?.acknowledged ?? false; + String? get taskId => task?.taskId; @override - List get props => []; + List get props => [task, isListening]; TaskStatusState copyWith({ Task? task, @@ -28,7 +26,6 @@ class TaskStatusState extends Equatable { return TaskStatusState( task: task ?? this.task, isListening: isListening ?? this.isListening, - isAcknowledged: isAcknowledged ?? this.isAcknowledged, ); } } diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 06d3b2b..923ebb0 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -194,6 +194,8 @@ "@editLabelPageConfirmDeletionDialogTitle": {}, "editLabelPageDeletionDialogText": "Dokumenty mají přiřazen tento štítek. Odstraněním štítku bude označení odstraněno. Pokračovat?", "@editLabelPageDeletionDialogText": {}, + "errorMessageAcknowledgeTasksError": "", + "@errorMessageAcknowledgeTasksError": {}, "errorMessageAuthenticationFailed": "Přihlášení selhalo, zkuste to znovu.", "@errorMessageAuthenticationFailed": {}, "errorMessageAutocompleteQueryError": "Při automatickém doplnění požadavku došlo k chybě.", @@ -250,6 +252,8 @@ "@errorMessageStoragePathCreateFailed": {}, "errorMessageStoragePathLoadFailed": "Nelze načíst cestu k úložišti.", "@errorMessageStoragePathLoadFailed": {}, + "errorMessageSuggestionsQueryError": "", + "@errorMessageSuggestionsQueryError": {}, "errorMessageTagCreateFailed": "Nelze vytvořit tag, zkuste to znovu.", "@errorMessageTagCreateFailed": {}, "errorMessageTagLoadFailed": "Nelze načíst tagy.", @@ -260,25 +264,25 @@ "@errorMessageUnsupportedFileFormat": {}, "errorReportLabel": "NAHLÁSIT", "@errorReportLabel": {}, - "extendedDateRangeDialogAbsoluteLabel": "Absolute", + "extendedDateRangeDialogAbsoluteLabel": "", "@extendedDateRangeDialogAbsoluteLabel": {}, - "extendedDateRangeDialogHintText": "Hint: Apart from concrete dates, you can also specify a time range relative to the current date.", + "extendedDateRangeDialogHintText": "", "@extendedDateRangeDialogHintText": {}, - "extendedDateRangeDialogRelativeAmountLabel": "Amount", + "extendedDateRangeDialogRelativeAmountLabel": "", "@extendedDateRangeDialogRelativeAmountLabel": {}, - "extendedDateRangeDialogRelativeLabel": "Relative", + "extendedDateRangeDialogRelativeLabel": "", "@extendedDateRangeDialogRelativeLabel": {}, - "extendedDateRangeDialogRelativeLastLabel": "Last", + "extendedDateRangeDialogRelativeLastLabel": "", "@extendedDateRangeDialogRelativeLastLabel": {}, - "extendedDateRangeDialogRelativeTimeUnitLabel": "Time unit", + "extendedDateRangeDialogRelativeTimeUnitLabel": "", "@extendedDateRangeDialogRelativeTimeUnitLabel": {}, - "extendedDateRangeDialogTitle": "Select date range", + "extendedDateRangeDialogTitle": "", "@extendedDateRangeDialogTitle": {}, - "extendedDateRangePickerAfterLabel": "After", + "extendedDateRangePickerAfterLabel": "", "@extendedDateRangePickerAfterLabel": {}, - "extendedDateRangePickerBeforeLabel": "Before", + "extendedDateRangePickerBeforeLabel": "", "@extendedDateRangePickerBeforeLabel": {}, - "extendedDateRangePickerDayText": "{count, plural, zero{} one{day} other{days}}", + "extendedDateRangePickerDayText": "{count, plural, other{}}", "@extendedDateRangePickerDayText": { "placeholders": { "count": {} @@ -298,9 +302,9 @@ "count": {} } }, - "extendedDateRangePickerLastText": "Last", + "extendedDateRangePickerLastText": "", "@extendedDateRangePickerLastText": {}, - "extendedDateRangePickerLastWeeksLabel": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}", + "extendedDateRangePickerLastWeeksLabel": "{count, plural, other{}}", "@extendedDateRangePickerLastWeeksLabel": { "placeholders": { "count": {} @@ -312,7 +316,7 @@ "count": {} } }, - "extendedDateRangePickerMonthText": "{count, plural, zero{} one{month} other{months}}", + "extendedDateRangePickerMonthText": "{count, plural, other{}}", "@extendedDateRangePickerMonthText": { "placeholders": { "count": {} @@ -320,13 +324,13 @@ }, "extendedDateRangePickerToLabel": "Do", "@extendedDateRangePickerToLabel": {}, - "extendedDateRangePickerWeekText": "{count, plural, zero{} one{week} other{weeks}}", + "extendedDateRangePickerWeekText": "{count, plural, other{}}", "@extendedDateRangePickerWeekText": { "placeholders": { "count": {} } }, - "extendedDateRangePickerYearText": "{count, plural, zero{} one{year} other{years}}", + "extendedDateRangePickerYearText": "{count, plural, other{}}", "@extendedDateRangePickerYearText": { "placeholders": { "count": {} @@ -428,7 +432,7 @@ "@loginPageClientCertificateSettingLabel": {}, "loginPageClientCertificateSettingSelectFileText": "Vybrat soubor...", "@loginPageClientCertificateSettingSelectFileText": {}, - "loginPageContinueLabel": "Continue", + "loginPageContinueLabel": "", "@loginPageContinueLabel": {}, "loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": "Chybná nebo chybějící heslová fráze certifikátu.", "@loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": {}, @@ -438,27 +442,27 @@ "@loginPagePasswordFieldLabel": {}, "loginPagePasswordValidatorMessageText": "Heslo nesmí být prázdné.", "@loginPagePasswordValidatorMessageText": {}, - "loginPageReachabilityInvalidClientCertificateConfigurationText": "Incorrect or missing client certificate passphrase.", + "loginPageReachabilityInvalidClientCertificateConfigurationText": "", "@loginPageReachabilityInvalidClientCertificateConfigurationText": {}, - "loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.", + "loginPageReachabilityMissingClientCertificateText": "", "@loginPageReachabilityMissingClientCertificateText": {}, - "loginPageReachabilityNotReachableText": "Could not establish a connection to the server.", + "loginPageReachabilityNotReachableText": "", "@loginPageReachabilityNotReachableText": {}, - "loginPageReachabilitySuccessText": "Connection successfully established.", + "loginPageReachabilitySuccessText": "", "@loginPageReachabilitySuccessText": {}, - "loginPageReachabilityUnresolvedHostText": "Host could not be resolved. Please check the server address.", + "loginPageReachabilityUnresolvedHostText": "", "@loginPageReachabilityUnresolvedHostText": {}, "loginPageServerUrlFieldLabel": "'Adresa serveru", "@loginPageServerUrlFieldLabel": {}, - "loginPageServerUrlValidatorMessageInvalidAddressText": "Invalid address.", + "loginPageServerUrlValidatorMessageInvalidAddressText": "", "@loginPageServerUrlValidatorMessageInvalidAddressText": {}, "loginPageServerUrlValidatorMessageRequiredText": "Adresa serveru nesmí být prázdná.", "@loginPageServerUrlValidatorMessageRequiredText": {}, - "loginPageSignInButtonLabel": "Sign In", + "loginPageSignInButtonLabel": "", "@loginPageSignInButtonLabel": {}, - "loginPageSignInTitle": "Sign In", + "loginPageSignInTitle": "", "@loginPageSignInTitle": {}, - "loginPageSignInToPrefixText": "Sign in to {serverAddress}", + "loginPageSignInToPrefixText": "", "@loginPageSignInToPrefixText": { "placeholders": { "serverAddress": {} @@ -476,7 +480,7 @@ "@onboardingDoneButtonLabel": {}, "onboardingNextButtonLabel": "Další", "@onboardingNextButtonLabel": {}, - "receiveSharedFilePermissionDeniedMessage": "Could not access the received file. Please try to open the app before sharing.", + "receiveSharedFilePermissionDeniedMessage": "", "@receiveSharedFilePermissionDeniedMessage": {}, "referencedDocumentsReadOnlyHintText": "Tento náhled nelze upravovat! Nelze upravovat nebo odstraňovat dokumenty. Bude načteno maximálně 100 odkazovaných dokumentů.", "@referencedDocumentsReadOnlyHintText": {}, @@ -540,12 +544,12 @@ "@tagInboxTagPropertyLabel": {}, "uploadPageAutomaticallInferredFieldsHintText": "Pokud specifikuješ hodnoty pro tato pole, paperless instance nebude automaticky přiřazovat naučené hodnoty. Pokud mají být tato pole automaticky vyplňována, nevyplňujte zde nic.", "@uploadPageAutomaticallInferredFieldsHintText": {}, - "verifyIdentityPageDescriptionText": "Use the configured biometric factor to authenticate and unlock your documents.", + "verifyIdentityPageDescriptionText": "", "@verifyIdentityPageDescriptionText": {}, - "verifyIdentityPageLogoutButtonLabel": "Disconnect", + "verifyIdentityPageLogoutButtonLabel": "", "@verifyIdentityPageLogoutButtonLabel": {}, - "verifyIdentityPageTitle": "Verify your identity", + "verifyIdentityPageTitle": "", "@verifyIdentityPageTitle": {}, - "verifyIdentityPageVerifyIdentityButtonLabel": "Verify Identity", + "verifyIdentityPageVerifyIdentityButtonLabel": "", "@verifyIdentityPageVerifyIdentityButtonLabel": {} } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 4f8231f..7605ba8 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -194,6 +194,8 @@ "@editLabelPageConfirmDeletionDialogTitle": {}, "editLabelPageDeletionDialogText": "Dieser Kennzeichner wird von Dokumenten referenziert. Durch das Löschen dieses Kennzeichners werden alle Referenzen entfernt. Fortfahren?", "@editLabelPageDeletionDialogText": {}, + "errorMessageAcknowledgeTasksError": "Dateiaufgabe konnte nicht verworfen werden.", + "@errorMessageAcknowledgeTasksError": {}, "errorMessageAuthenticationFailed": "Authentifizierung fehlgeschlagen, bitte versuche es erneut.", "@errorMessageAuthenticationFailed": {}, "errorMessageAutocompleteQueryError": "Beim automatischen Vervollständigen ist ein Fehler aufgetreten.", @@ -250,6 +252,8 @@ "@errorMessageStoragePathCreateFailed": {}, "errorMessageStoragePathLoadFailed": "Speicherpfade konnten nicht geladen werden.", "@errorMessageStoragePathLoadFailed": {}, + "errorMessageSuggestionsQueryError": "Vorschläge konnten nicht geladen werden.", + "@errorMessageSuggestionsQueryError": {}, "errorMessageTagCreateFailed": "Tag konnte nicht erstellt werden, bitte versuche es erneut.", "@errorMessageTagCreateFailed": {}, "errorMessageTagLoadFailed": "Tags konnten nicht geladen werden.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 3296c35..30b3f3e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -194,6 +194,8 @@ "@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.", @@ -250,6 +252,8 @@ "@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.", diff --git a/lib/main.dart b/lib/main.dart index 64ddbeb..73aa4b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,7 +14,6 @@ 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/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart'; @@ -27,7 +26,7 @@ import 'package:paperless_mobile/core/repository/state/impl/correspondent_reposi 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/authentication_aware_dio_manager.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'; @@ -46,7 +45,6 @@ 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:path_provider/path_provider.dart'; -import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:provider/provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; @@ -55,7 +53,6 @@ void main() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); await findSystemLocale(); - await LocalNotificationService.instance.initialize(); // Initialize External dependencies final connectivity = Connectivity(); @@ -78,27 +75,18 @@ void main() async { final languageHeaderInterceptor = LanguageHeaderInterceptor( appSettingsCubit.state.preferredLocaleSubtag, ); - // Required for self signed client certificates - final dioWrapper = AuthenticationAwareDioManager([ - DioHttpErrorInterceptor(), - PrettyDioLogger( - compact: true, - responseBody: false, - responseHeader: false, - request: false, - requestBody: false, - requestHeader: false, - ), - languageHeaderInterceptor, - ]); + // Manages security context, required for self signed client certificates + final sessionManager = SessionManager([languageHeaderInterceptor]); // Initialize Paperless APIs - final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client); - final documentsApi = PaperlessDocumentsApiImpl(dioWrapper.client); - final labelsApi = PaperlessLabelApiImpl(dioWrapper.client); - final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client); - final savedViewsApi = PaperlessSavedViewsApiImpl(dioWrapper.client); - final tasksApi = PaperlessTasksApiImpl(dioWrapper.client); + final authApi = PaperlessAuthenticationApiImpl(sessionManager.client); + final documentsApi = PaperlessDocumentsApiImpl(sessionManager.client); + final labelsApi = PaperlessLabelApiImpl(sessionManager.client); + final statsApi = PaperlessServerStatsApiImpl(sessionManager.client); + final savedViewsApi = PaperlessSavedViewsApiImpl(sessionManager.client); + final tasksApi = PaperlessTasksApiImpl( + sessionManager.client, + ); // Initialize Blocs/Cubits final connectivityCubit = ConnectivityCubit(connectivityStatusService); @@ -119,20 +107,23 @@ void main() async { final authCubit = AuthenticationCubit( localAuthService, authApi, - dioWrapper, + sessionManager, ); await authCubit .restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled); if (authCubit.state.isAuthenticated) { final auth = authCubit.state.authentication!; - dioWrapper.updateSettings( + sessionManager.updateSettings( baseUrl: auth.serverUrl, authToken: auth.token, clientCertificate: auth.clientCertificate, ); } + final localNotificationService = LocalNotificationService(); + await localNotificationService.initialize(); + //Update language header in interceptor on language change. appSettingsCubit.stream.listen((event) => languageHeaderInterceptor .preferredLocaleSubtag = event.preferredLocaleSubtag); @@ -149,7 +140,7 @@ void main() async { create: (context) => cm.CacheManager( cm.Config( 'cacheKey', - fileService: DioFileService(dioWrapper.client), + fileService: DioFileService(sessionManager.client), ), ), ), @@ -157,6 +148,9 @@ void main() async { Provider.value( value: connectivityStatusService, ), + Provider.value( + value: localNotificationService, + ), ], child: MultiRepositoryProvider( providers: [ @@ -221,6 +215,7 @@ class _PaperlessMobileEntrypointState extends State { vertical: 16.0, ), ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, chipTheme: ChipThemeData( backgroundColor: Colors.lightGreen[50], ), @@ -242,6 +237,7 @@ class _PaperlessMobileEntrypointState extends State { vertical: 16.0, ), ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, chipTheme: ChipThemeData( backgroundColor: Colors.green[900], ), @@ -252,7 +248,9 @@ class _PaperlessMobileEntrypointState extends State { return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => PaperlessServerInformationCubit(context.read()), + create: (context) => PaperlessServerInformationCubit( + context.read(), + ), ), ], child: BlocBuilder( @@ -260,14 +258,8 @@ class _PaperlessMobileEntrypointState extends State { return MaterialApp( debugShowCheckedModeBanner: true, title: "Paperless Mobile", - theme: _lightTheme.copyWith( - listTileTheme: _lightTheme.listTileTheme - .copyWith(tileColor: Colors.transparent), - ), - darkTheme: _darkTheme.copyWith( - listTileTheme: _darkTheme.listTileTheme - .copyWith(tileColor: Colors.transparent), - ), + theme: _lightTheme, + darkTheme: _darkTheme, themeMode: settings.preferredThemeMode, supportedLocales: S.delegate.supportedLocales, locale: Locale.fromSubtags( diff --git a/packages/paperless_api/lib/src/models/document_model.dart b/packages/paperless_api/lib/src/models/document_model.dart index ab2e7f7..1d9ec0a 100644 --- a/packages/paperless_api/lib/src/models/document_model.dart +++ b/packages/paperless_api/lib/src/models/document_model.dart @@ -131,6 +131,6 @@ class DocumentModel extends Equatable { archiveSerialNumber, originalFileName, archivedFileName, - storagePath + storagePath, ]; } diff --git a/packages/paperless_api/lib/src/models/field_suggestions.dart b/packages/paperless_api/lib/src/models/field_suggestions.dart new file mode 100644 index 0000000..86f5ba0 --- /dev/null +++ b/packages/paperless_api/lib/src/models/field_suggestions.dart @@ -0,0 +1,45 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'field_suggestions.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake) +class FieldSuggestions { + final Iterable correspondents; + final Iterable tags; + final Iterable documentTypes; + final Iterable storagePaths; + final Iterable dates; + + const FieldSuggestions({ + this.correspondents = const [], + this.tags = const [], + this.documentTypes = const [], + this.storagePaths = const [], + this.dates = const [], + }); + + bool get hasSuggestedCorrespondents => correspondents.isNotEmpty; + bool get hasSuggestedTags => tags.isNotEmpty; + bool get hasSuggestedDocumentTypes => documentTypes.isNotEmpty; + bool get hasSuggestedStoragePaths => storagePaths.isNotEmpty; + bool get hasSuggestedDates => dates.isNotEmpty; + + bool get hasSuggestions => + hasSuggestedCorrespondents || + hasSuggestedDates || + hasSuggestedTags || + hasSuggestedStoragePaths || + hasSuggestedDocumentTypes; + + int get suggestionsCount => + (correspondents.isNotEmpty ? 1 : 0) + + (tags.isNotEmpty ? 1 : 0) + + (documentTypes.isNotEmpty ? 1 : 0) + + (storagePaths.isNotEmpty ? 1 : 0) + + (dates.isNotEmpty ? 1 : 0); + + factory FieldSuggestions.fromJson(Map json) => + _$FieldSuggestionsFromJson(json); + + Map toJson() => _$FieldSuggestionsToJson(this); +} diff --git a/packages/paperless_api/lib/src/models/field_suggestions.g.dart b/packages/paperless_api/lib/src/models/field_suggestions.g.dart new file mode 100644 index 0000000..34d1caf --- /dev/null +++ b/packages/paperless_api/lib/src/models/field_suggestions.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'field_suggestions.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FieldSuggestions _$FieldSuggestionsFromJson(Map json) => + FieldSuggestions( + correspondents: + (json['correspondents'] as List?)?.map((e) => e as int) ?? + const [], + tags: (json['tags'] as List?)?.map((e) => e as int) ?? const [], + documentTypes: + (json['document_types'] as List?)?.map((e) => e as int) ?? + const [], + storagePaths: + (json['storage_paths'] as List?)?.map((e) => e as int) ?? + const [], + dates: (json['dates'] as List?) + ?.map((e) => DateTime.parse(e as String)) ?? + const [], + ); + +Map _$FieldSuggestionsToJson(FieldSuggestions instance) => + { + 'correspondents': instance.correspondents.toList(), + 'tags': instance.tags.toList(), + 'document_types': instance.documentTypes.toList(), + 'storage_paths': instance.storagePaths.toList(), + 'dates': instance.dates.map((e) => e.toIso8601String()).toList(), + }; diff --git a/packages/paperless_api/lib/src/models/filter_rule_model.dart b/packages/paperless_api/lib/src/models/filter_rule_model.dart index d64f214..5ffc30f 100644 --- a/packages/paperless_api/lib/src/models/filter_rule_model.dart +++ b/packages/paperless_api/lib/src/models/filter_rule_model.dart @@ -346,15 +346,17 @@ class FilterRule with EquatableMixin { ); } - //Join values of all extended filter rules - final FilterRule extendedFilterRule = filterRules - .where((r) => r.ruleType == extendedRule) - .reduce((previousValue, element) => previousValue.copyWith( - value: previousValue.value! + element.value!, - )); - filterRules - ..removeWhere((element) => element.ruleType == extendedRule) - ..add(extendedFilterRule); + //Join values of all extended filter rules if exist + if (filterRules.isNotEmpty) { + final FilterRule extendedFilterRule = filterRules + .where((r) => r.ruleType == extendedRule) + .reduce((previousValue, element) => previousValue.copyWith( + value: previousValue.value! + element.value!, + )); + filterRules + ..removeWhere((element) => element.ruleType == extendedRule) + ..add(extendedFilterRule); + } return filterRules; } diff --git a/packages/paperless_api/lib/src/models/models.dart b/packages/paperless_api/lib/src/models/models.dart index a270fd0..3d43bd0 100644 --- a/packages/paperless_api/lib/src/models/models.dart +++ b/packages/paperless_api/lib/src/models/models.dart @@ -24,3 +24,4 @@ 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/paperless_server_exception.dart b/packages/paperless_api/lib/src/models/paperless_server_exception.dart index d1ff16b..10ada4d 100644 --- a/packages/paperless_api/lib/src/models/paperless_server_exception.dart +++ b/packages/paperless_api/lib/src/models/paperless_server_exception.dart @@ -43,6 +43,7 @@ enum ErrorCode { deviceOffline, serverUnreachable, similarQueryError, + suggestionsQueryError, autocompleteQueryError, storagePathLoadFailed, storagePathCreateFailed, @@ -51,5 +52,6 @@ enum ErrorCode { deleteSavedViewError, requestTimedOut, unsupportedFileFormat, - missingClientCertificate; + missingClientCertificate, + acknowledgeTasksError; } diff --git a/packages/paperless_api/lib/src/models/paperless_server_information_model.dart b/packages/paperless_api/lib/src/models/paperless_server_information_model.dart index 70fb3f4..a3c9db4 100644 --- a/packages/paperless_api/lib/src/models/paperless_server_information_model.dart +++ b/packages/paperless_api/lib/src/models/paperless_server_information_model.dart @@ -1,3 +1,5 @@ +import 'package:paperless_api/src/request_utils.dart'; + class PaperlessServerInformationModel { static const String versionHeader = 'x-version'; static const String apiVersionHeader = 'x-api-version'; @@ -13,4 +15,9 @@ class PaperlessServerInformationModel { this.version = 'unknown', this.apiVersion = 1, }); + + int compareToOtherVersion(String? other) { + return getExtendedVersionNumber(version ?? '0.0.0') + .compareTo(getExtendedVersionNumber(other ?? '0.0.0')); + } } diff --git a/packages/paperless_api/lib/src/models/task/task.dart b/packages/paperless_api/lib/src/models/task/task.dart index 28c6e93..d3e92c0 100644 --- a/packages/paperless_api/lib/src/models/task/task.dart +++ b/packages/paperless_api/lib/src/models/task/task.dart @@ -17,7 +17,7 @@ class Task extends Equatable { final String? result; final bool acknowledged; @JsonKey(fromJson: tryParseNullable) - final int? relatedDocumentId; + final int? relatedDocument; const Task({ required this.id, @@ -28,7 +28,7 @@ class Task extends Equatable { this.type, this.status, this.acknowledged = false, - this.relatedDocumentId, + this.relatedDocument, this.result, }); @@ -47,6 +47,32 @@ class Task extends Equatable { status, result, acknowledged, - relatedDocumentId, + relatedDocument, ]; + + Task copyWith({ + int? id, + String? taskId, + String? taskFileName, + DateTime? dateCreated, + DateTime? dateDone, + String? type, + TaskStatus? status, + String? result, + bool? acknowledged, + int? relatedDocument, + }) { + return Task( + id: id ?? this.id, + taskId: taskId ?? this.taskId, + dateCreated: dateCreated ?? this.dateCreated, + acknowledged: acknowledged ?? this.acknowledged, + dateDone: dateDone ?? this.dateDone, + relatedDocument: relatedDocument ?? this.relatedDocument, + result: result ?? this.result, + status: status ?? this.status, + taskFileName: taskFileName ?? this.taskFileName, + type: type ?? this.type, + ); + } } diff --git a/packages/paperless_api/lib/src/models/task/task.g.dart b/packages/paperless_api/lib/src/models/task/task.g.dart index fb1b969..71c3567 100644 --- a/packages/paperless_api/lib/src/models/task/task.g.dart +++ b/packages/paperless_api/lib/src/models/task/task.g.dart @@ -17,8 +17,7 @@ Task _$TaskFromJson(Map json) => Task( type: json['type'] as String?, status: $enumDecodeNullable(_$TaskStatusEnumMap, json['status']), acknowledged: json['acknowledged'] as bool? ?? false, - relatedDocumentId: - tryParseNullable(json['related_document_id'] as String?), + relatedDocument: tryParseNullable(json['related_document'] as String?), result: json['result'] as String?, ); @@ -32,7 +31,7 @@ Map _$TaskToJson(Task instance) => { 'status': _$TaskStatusEnumMap[instance.status], 'result': instance.result, 'acknowledged': instance.acknowledged, - 'related_document_id': instance.relatedDocumentId, + 'related_document': instance.relatedDocument, }; const _$TaskStatusEnumMap = { 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 b5936de..340469b 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 @@ -1,11 +1,6 @@ import 'dart:typed_data'; -import 'package:paperless_api/src/models/bulk_edit_model.dart'; -import 'package:paperless_api/src/models/document_filter.dart'; -import 'package:paperless_api/src/models/document_meta_data_model.dart'; -import 'package:paperless_api/src/models/document_model.dart'; -import 'package:paperless_api/src/models/paged_search_result.dart'; -import 'package:paperless_api/src/models/similar_document_model.dart'; +import 'package:paperless_api/src/models/models.dart'; abstract class PaperlessDocumentsApi { /// Uploads a document using a form data request and from server version 1.11.3 @@ -21,18 +16,16 @@ abstract class PaperlessDocumentsApi { }); Future update(DocumentModel doc); Future findNextAsn(); - Future> find(DocumentFilter filter); + Future> findAll(DocumentFilter filter); + Future find(int id); Future> findSimilar(int docId); Future delete(DocumentModel doc); Future getMetaData(DocumentModel document); Future> bulkAction(BulkAction action); Future getPreview(int docId); String getThumbnailUrl(int docId); - Future waitForConsumptionFinished( - String filename, - String title, - ); Future download(DocumentModel document); + Future findSuggestions(DocumentModel document); Future> autocomplete(String query, [int limit = 10]); } 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 6068007..8ff4764 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 @@ -1,13 +1,9 @@ import 'dart:convert'; -import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/src/constants.dart'; -import 'package:paperless_api/src/converters/document_model_json_converter.dart'; -import 'package:paperless_api/src/converters/similar_document_model_json_converter.dart'; -import 'package:paperless_api/src/request_utils.dart'; class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { final Dio client; @@ -82,8 +78,11 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } @override - Future> find(DocumentFilter filter) async { - final filterParams = filter.toQueryParameters(); + Future> findAll( + DocumentFilter filter, + ) async { + final filterParams = filter.toQueryParameters() + ..addAll({'truncate_content': "true"}); try { final response = await client.get( "/api/documents/", @@ -156,7 +155,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { pageSize: 1, ); try { - final result = await find(asnQueryFilter); + final result = await findAll(asnQueryFilter); return result.results .map((e) => e.archiveSerialNumber) .firstWhere((asn) => asn != null, orElse: () => 0)! + @@ -187,26 +186,6 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } } - @override - Future waitForConsumptionFinished( - String fileName, String title) async { - PagedSearchResult results = - await find(DocumentFilter.latestDocument); - - while ((results.results.isEmpty || - (results.results[0].originalFileName != fileName && - results.results[0].title != title))) { - //TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication... - await Future.delayed(const Duration(seconds: 2)); - results = await find(DocumentFilter.latestDocument); - } - try { - return results.results.first; - } on StateError { - throw const PaperlessServerException(ErrorCode.documentUploadFailed); - } - } - @override Future download(DocumentModel document) async { try { @@ -273,4 +252,32 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { throw err.error; } } + + @override + Future findSuggestions(DocumentModel document) async { + try { + final response = + await client.get("/api/documents/${document.id}/suggestions/"); + if (response.statusCode == 200) { + return FieldSuggestions.fromJson(response.data); + } + throw const PaperlessServerException(ErrorCode.suggestionsQueryError); + } on DioError catch (err) { + throw err.error; + } + } + + @override + Future find(int id) async { + try { + final response = await client.get("/api/documents/$id/"); + if (response.statusCode == 200) { + return DocumentModel.fromJson(response.data); + } else { + return null; + } + } on DioError catch (err) { + throw err.error; + } + } } diff --git a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart index cdb20a6..daa8589 100644 --- a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart +++ b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api.dart @@ -4,4 +4,6 @@ abstract class PaperlessTasksApi { Future find({int? id, String? taskId}); Future> findAll([Iterable? ids]); Stream listenForTaskChanges(String taskId); + Future acknowledgeTask(Task task); + Future> acknowledgeTasks(Iterable tasks); } diff --git a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart index 2a700b8..e38ba81 100644 --- a/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/tasks_api/paperless_tasks_api_impl.dart @@ -1,34 +1,48 @@ -import 'package:dio/dio.dart'; -import 'package:paperless_api/src/models/task/task.dart'; -import 'package:paperless_api/src/models/task/task_status.dart'; +import 'dart:developer'; -import 'paperless_tasks_api.dart'; +import 'package:dio/dio.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_api/src/request_utils.dart'; class PaperlessTasksApiImpl implements PaperlessTasksApi { - final Dio client; + final Dio _client; - const PaperlessTasksApiImpl(this.client); + PaperlessTasksApiImpl(this._client); @override Future find({int? id, String? taskId}) async { - assert(id != null || taskId != null); - String url = "/api/tasks/"; - if (taskId != null) { - url += "?task_id=$taskId"; - } else { - url += "$id/"; + assert((id != null) != (taskId != null)); + if (id != null) { + return _findById(id); + } else if (taskId != null) { + return _findByTaskId(taskId); } + return null; + } - final response = await client.get(url); + /// API response returns List with single item + Future _findById(int id) async { + final response = await _client.get("/api/tasks/$id/"); if (response.statusCode == 200) { return Task.fromJson(response.data); } return null; } + /// API response returns List with single item + Future _findByTaskId(String taskId) async { + final response = await _client.get("/api/tasks/?task_id=$taskId"); + if (response.statusCode == 200) { + if ((response.data as List).isNotEmpty) { + return Task.fromJson((response.data as List).first); + } + } + return null; + } + @override Future> findAll([Iterable? ids]) async { - final response = await client.get("/api/tasks/"); + final response = await _client.get("/api/tasks/"); if (response.statusCode == 200) { return (response.data as List).map((e) => Task.fromJson(e)); } @@ -37,17 +51,39 @@ class PaperlessTasksApiImpl implements PaperlessTasksApi { @override Stream listenForTaskChanges(String taskId) async* { - bool isSuccess = false; - while (!isSuccess) { + bool isCompleted = false; + while (!isCompleted) { final task = await find(taskId: taskId); if (task == null) { throw Exception("Task with taskId $taskId does not exist."); } + log("Found new task: ${task.taskId}, ${task.id}, ${task.status}"); yield task; - if (task.status == TaskStatus.success) { - isSuccess = true; + if (task.status == TaskStatus.success || + task.status == TaskStatus.failure) { + isCompleted = true; } await Future.delayed(const Duration(seconds: 1)); } } + + @override + Future acknowledgeTask(Task task) async { + final acknowledgedTasks = await acknowledgeTasks([task]); + return acknowledgedTasks.first.copyWith(acknowledged: true); + } + + @override + Future> acknowledgeTasks(Iterable tasks) async { + final response = await _client.post("/api/acknowledge_tasks/", data: { + 'tasks': tasks.map((e) => e.id).toList(), + }); + if (response.statusCode == 200) { + if (response.data['result'] != tasks.length) { + throw const PaperlessServerException(ErrorCode.acknowledgeTasksError); + } + return tasks.map((e) => e.copyWith(acknowledged: true)).toList(); + } + throw const PaperlessServerException(ErrorCode.acknowledgeTasksError); + } } diff --git a/pubspec.lock b/pubspec.lock index 7d7de2b..6bed59a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -633,6 +633,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "1312314293acceb65b92754298754801b0e1f26a1845833b740b30415bbbcf07" + url: "https://pub.dev" + source: hosted + version: "0.6.2" flutter_svg: dependency: "direct main" description: @@ -696,14 +704,6 @@ packages: description: flutter source: sdk version: "0.0.0" - get_it: - dependency: transitive - description: - name: get_it - sha256: "290fde3a86072e4b37dbb03c07bec6126f0ecc28dad403c12ffe2e5a2d751ab7" - url: "https://pub.dev" - source: hosted - version: "7.2.0" glob: dependency: transitive description: @@ -784,30 +784,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" - infinite_scroll_pagination: - dependency: "direct main" - description: - name: infinite_scroll_pagination - sha256: "9517328f4e373f08f57dbb11c5aac5b05554142024d6b60c903f3b73476d52db" - url: "https://pub.dev" - source: hosted - version: "3.2.0" - injectable: - dependency: transitive - description: - name: injectable - sha256: "7dab7d341feb40a0590d9ff6261aea9495522005e2c6763f9161a4face916f7b" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - injectable_generator: - dependency: "direct dev" - description: - name: injectable_generator - sha256: "9a3bbd2c3ba821e31ef6cea3fc535c17e3a25c74e173b6cefa05f466c8338bc8" - url: "https://pub.dev" - source: hosted - version: "2.1.3" integration_test: dependency: "direct dev" description: flutter @@ -1308,14 +1284,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" receive_sharing_intent: dependency: "direct main" description: @@ -1457,14 +1425,6 @@ packages: description: flutter source: sdk version: "0.0.99" - sliver_tools: - dependency: transitive - description: - name: sliver_tools - sha256: edf005f1a47c2ffa6f1e1a4f24dd99c45b8bccfff9b928d39170d36dc6fda871 - url: "https://pub.dev" - source: hosted - version: "0.2.8" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 079512b..102b7c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,7 +57,6 @@ dependencies: equatable: ^2.0.3 flutter_form_builder: ^7.5.0 form_builder_validators: ^8.4.0 - infinite_scroll_pagination: ^3.2.0 package_info_plus: ^1.4.3+1 font_awesome_flutter: ^10.1.0 local_auth: ^2.1.2 @@ -85,6 +84,7 @@ dependencies: collection: ^1.17.0 device_info_plus: ^4.1.3 flutter_local_notifications: ^13.0.0 + flutter_staggered_grid_view: ^0.6.2 dev_dependencies: integration_test: @@ -92,7 +92,6 @@ dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.1.11 - injectable_generator: ^2.1.0 mockito: ^5.3.2 bloc_test: ^9.1.0 dependency_validator: ^3.0.0