From 1172e54199c36463429846ab50692300a8c9aee2 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Mon, 6 Mar 2023 22:21:14 +0100 Subject: [PATCH 1/2] feat: Improve notifications, add donation button, improved asn form field --- .../app/src/main/res/drawable/downloading.png | Bin 0 -> 1278 bytes .../main/res/drawable/file_download_done.png | Bin 0 -> 405 bytes .../res/drawable/paperless_logo_green.png | Bin 0 -> 35355 bytes assets/images/bmc-logo.svg | 16 ++ lib/core/bloc/connectivity_cubit.dart | 8 + lib/features/app_drawer/view/app_drawer.dart | 16 ++ .../cubit/document_details_cubit.dart | 121 ++++++++-- .../cubit/document_details_state.dart | 5 + .../view/dialogs/select_file_type_dialog.dart | 25 ++ .../view/pages/document_details_page.dart | 75 +++--- .../widgets/archive_serial_number_field.dart | 145 ++++++++++++ .../widgets/document_download_button.dart | 43 ++-- .../widgets/document_meta_data_widget.dart | 112 ++++----- .../view/widgets/document_share_button.dart | 78 +++++++ .../view/document_edit_page.dart | 220 ++++++++++-------- lib/features/inbox/cubit/inbox_cubit.dart | 2 +- .../notification_tap_response_payload.dart | 25 ++ .../models/notification_actions.dart | 11 + .../notification_channels.dart | 3 +- .../create_document_success_payload.dart | 15 ++ .../notification_tap_response_payload.dart | 8 + .../open_downloaded_document_payload.dart | 19 ++ .../services/local_notification_service.dart | 130 +++++++++-- ...created_document_notification_payload.dart | 17 -- .../services/notification_actions.dart | 4 - lib/l10n/intl_cs.arb | 18 +- lib/l10n/intl_de.arb | 18 +- lib/l10n/intl_en.arb | 18 +- lib/l10n/intl_fr.arb | 36 ++- lib/l10n/intl_pl.arb | 18 +- lib/l10n/intl_tr.arb | 18 +- lib/routes/document_details_route.dart | 1 + .../lib/src/models/document_model.dart | 13 +- .../paperless_documents_api.dart | 6 + .../paperless_documents_api_impl.dart | 23 +- pubspec.lock | 16 ++ pubspec.yaml | 1 + 37 files changed, 982 insertions(+), 302 deletions(-) create mode 100644 android/app/src/main/res/drawable/downloading.png create mode 100644 android/app/src/main/res/drawable/file_download_done.png create mode 100644 android/app/src/main/res/drawable/paperless_logo_green.png create mode 100644 assets/images/bmc-logo.svg create mode 100644 lib/features/document_details/view/dialogs/select_file_type_dialog.dart create mode 100644 lib/features/document_details/view/widgets/archive_serial_number_field.dart create mode 100644 lib/features/document_details/view/widgets/document_share_button.dart create mode 100644 lib/features/notifications/converters/notification_tap_response_payload.dart create mode 100644 lib/features/notifications/models/notification_actions.dart rename lib/features/notifications/{services => models}/notification_channels.dart (51%) create mode 100644 lib/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart create mode 100644 lib/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart create mode 100644 lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart delete mode 100644 lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart delete mode 100644 lib/features/notifications/services/notification_actions.dart diff --git a/android/app/src/main/res/drawable/downloading.png b/android/app/src/main/res/drawable/downloading.png new file mode 100644 index 0000000000000000000000000000000000000000..993bbef8f1eb44ae3a2c45c2a605db7ef792a028 GIT binary patch literal 1278 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

a7TBh9n)F&s_yp2@ZNT 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 yhkz`3)k6{Emr2!w6@@r%1oXr+K(Ph + + + + + + + + + + + + + + + diff --git a/lib/core/bloc/connectivity_cubit.dart b/lib/core/bloc/connectivity_cubit.dart index 5e53f51d..edbcf6b0 100644 --- a/lib/core/bloc/connectivity_cubit.dart +++ b/lib/core/bloc/connectivity_cubit.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -45,6 +46,13 @@ class ConnectivityCubit extends Cubit { } } +extension ConnectivityFromContext on BuildContext { + bool get watchInternetConnection => + watch().state.isConnected; + bool get readInternetConnection => + read().state.isConnected; +} + enum ConnectivityState { connected, notConnected, diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 59a12945..7dd6b125 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -45,6 +46,21 @@ class AppDrawer extends StatelessWidget { 'https://github.com/astubenbord/paperless-mobile/issues/new'); }, ), + ListTile( + dense: true, + leading: Padding( + padding: const EdgeInsets.only(left: 3), + child: SvgPicture.asset( + 'assets/images/bmc-logo.svg', + width: 24, + height: 24, + ), + ), + title: Text(S.of(context)!.donateCoffee), + onTap: () { + launchUrlString("https://www.buymeacoffee.com/astubenbord"); + }, + ), ListTile( dense: true, leading: const Icon(Icons.settings_outlined), diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index d5ac4bc4..7c8e90b3 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -3,10 +3,13 @@ import 'dart:io'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; +import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -15,15 +18,18 @@ part 'document_details_state.dart'; class DocumentDetailsCubit extends Cubit { final PaperlessDocumentsApi _api; final DocumentChangedNotifier _notifier; + final LocalNotificationService _notificationService; final List _subscriptions = []; DocumentDetailsCubit( this._api, - this._notifier, { + this._notifier, + this._notificationService, { required DocumentModel initialDocument, }) : super(DocumentDetailsState(document: initialDocument)) { _notifier.subscribe(this, onUpdated: replace); loadSuggestions(); + loadMetaData(); } Future delete(DocumentModel document) async { @@ -36,6 +42,11 @@ class DocumentDetailsCubit extends Cubit { emit(state.copyWith(suggestions: suggestions)); } + Future loadMetaData() async { + final metaData = await _api.getMetaData(state.document); + emit(state.copyWith(metaData: metaData)); + } + Future loadFullContent() async { final doc = await _api.find(state.document.id); if (doc == null) { @@ -47,11 +58,20 @@ class DocumentDetailsCubit extends Cubit { )); } - Future assignAsn(DocumentModel document) async { - if (document.archiveSerialNumber == null) { - final int asn = await _api.findNextAsn(); - final updatedDocument = - await _api.update(document.copyWith(archiveSerialNumber: asn)); + Future assignAsn( + DocumentModel document, { + int? asn, + bool autoAssign = false, + }) async { + if (!autoAssign) { + final updatedDocument = await _api.update( + document.copyWith(archiveSerialNumber: () => asn), + ); + _notifier.notifyUpdated(updatedDocument); + } else { + final int autoAsn = await _api.findNextAsn(); + final updatedDocument = await _api + .update(document.copyWith(archiveSerialNumber: () => autoAsn)); _notifier.notifyUpdated(updatedDocument); } } @@ -59,14 +79,19 @@ class DocumentDetailsCubit extends Cubit { Future openDocumentInSystemViewer() async { final cacheDir = await FileService.temporaryDirectory; - final metaData = await _api.getMetaData(state.document); - final bytes = await _api.download(state.document); + if (state.metaData == null) { + await loadMetaData(); + } - final file = File('${cacheDir.path}/${metaData.mediaFilename}') - ..createSync(recursive: true) - ..writeAsBytesSync(bytes); + await _api.downloadToFile( + state.document, + '${cacheDir.path}/${state.metaData!.mediaFilename}', + ); - return OpenFilex.open(file.path, type: "application/pdf").then( + return OpenFilex.open( + '${cacheDir.path}/${state.metaData!.mediaFilename}', + type: "application/pdf", + ).then( (value) => value.type, ); } @@ -75,15 +100,60 @@ class DocumentDetailsCubit extends Cubit { emit(state.copyWith(document: document)); } - Future shareDocument() async { - final documentBytes = await _api.download(state.document); - final dir = await getTemporaryDirectory(); - final String path = "${dir.path}/${state.document.originalFileName}"; - await File(path).writeAsBytes(documentBytes); + Future downloadDocument({ + bool downloadOriginal = false, + required String locale, + }) async { + if (state.metaData == null) { + await loadMetaData(); + } + String filePath = _buildDownloadFilePath( + downloadOriginal, + await FileService.downloadsDirectory, + ); + final desc = FileDescription.fromPath( + state.metaData!.mediaFilename + .replaceAll("/", " "), // Flatten directory structure + ); + await _notificationService.notifyFileDownload( + document: state.document, + filename: "${desc.filename}.${desc.extension}", + filePath: filePath, + finished: false, + locale: locale, + ); + await _api.downloadToFile( + state.document, + filePath, + original: downloadOriginal, + ); + await _notificationService.notifyFileDownload( + document: state.document, + filename: "${desc.filename}.${desc.extension}", + filePath: filePath, + finished: true, + locale: locale, + ); + debugPrint("Downloaded file to $filePath"); + } + + Future shareDocument({bool shareOriginal = false}) async { + if (state.metaData == null) { + await loadMetaData(); + } + String filePath = _buildDownloadFilePath( + shareOriginal, + await FileService.temporaryDirectory, + ); + await _api.downloadToFile( + state.document, + filePath, + original: shareOriginal, + ); Share.shareXFiles( [ XFile( - path, + filePath, name: state.document.originalFileName, mimeType: "application/pdf", lastModified: state.document.modified, @@ -93,12 +163,21 @@ class DocumentDetailsCubit extends Cubit { ); } + String _buildDownloadFilePath(bool original, Directory dir) { + final description = FileDescription.fromPath( + state.metaData!.mediaFilename + .replaceAll("/", " "), // Flatten directory structure + ); + final extension = original ? description.extension : 'pdf'; + return "${dir.path}/${description.filename}.$extension"; + } + @override - Future close() { + Future close() async { for (final element in _subscriptions) { - element.cancel(); + await element.cancel(); } _notifier.unsubscribe(this); - return super.close(); + await super.close(); } } diff --git a/lib/features/document_details/cubit/document_details_state.dart b/lib/features/document_details/cubit/document_details_state.dart index 29a23594..35c2e1bd 100644 --- a/lib/features/document_details/cubit/document_details_state.dart +++ b/lib/features/document_details/cubit/document_details_state.dart @@ -2,12 +2,14 @@ part of 'document_details_cubit.dart'; class DocumentDetailsState with EquatableMixin { final DocumentModel document; + final DocumentMetaData? metaData; final bool isFullContentLoaded; final String? fullContent; final FieldSuggestions suggestions; const DocumentDetailsState({ required this.document, + this.metaData, this.suggestions = const FieldSuggestions(), this.isFullContentLoaded = false, this.fullContent, @@ -19,6 +21,7 @@ class DocumentDetailsState with EquatableMixin { suggestions, isFullContentLoaded, fullContent, + metaData, ]; DocumentDetailsState copyWith({ @@ -26,12 +29,14 @@ class DocumentDetailsState with EquatableMixin { FieldSuggestions? suggestions, bool? isFullContentLoaded, String? fullContent, + DocumentMetaData? metaData, }) { return DocumentDetailsState( document: document ?? this.document, suggestions: suggestions ?? this.suggestions, isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded, fullContent: fullContent ?? this.fullContent, + metaData: metaData ?? this.metaData, ); } } diff --git a/lib/features/document_details/view/dialogs/select_file_type_dialog.dart b/lib/features/document_details/view/dialogs/select_file_type_dialog.dart new file mode 100644 index 00000000..21d62565 --- /dev/null +++ b/lib/features/document_details/view/dialogs/select_file_type_dialog.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; + +class SelectFileTypeDialog extends StatelessWidget { + const SelectFileTypeDialog({super.key}); + + @override + Widget build(BuildContext context) { + return RadioSettingsDialog( + titleText: S.of(context)!.chooseFiletype, + options: [ + RadioOption( + value: true, + label: S.of(context)!.original, + ), + RadioOption( + value: false, + label: S.of(context)!.archivedPdf, + ), + ], + initialValue: false, + ); + } +} 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 04575205..a0e5d171 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -12,6 +12,7 @@ import 'package:paperless_mobile/features/document_details/view/widgets/document import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_meta_data_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; +import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; @@ -40,7 +41,7 @@ class DocumentDetailsPage extends StatefulWidget { class _DocumentDetailsPageState extends State { late Future _metaData; - static const double _itemPadding = 24; + static const double _itemSpacing = 24; @override void initState() { super.initState(); @@ -71,6 +72,7 @@ class _DocumentDetailsPageState extends State { setState(() {}); }, child: Scaffold( + extendBodyBehindAppBar: false, floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, floatingActionButton: widget.allowEdit ? _buildEditButton() : null, @@ -78,15 +80,47 @@ class _DocumentDetailsPageState extends State { body: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverAppBar( + title: Text(context + .watch() + .state + .document + .title), leading: const BackButton(), - floating: true, pinned: true, - expandedHeight: 200.0, - flexibleSpace: - BlocBuilder( - builder: (context, state) => DocumentPreview( - document: state.document, - fit: BoxFit.cover, + forceElevated: innerBoxIsScrolled, + collapsedHeight: kToolbarHeight, + expandedHeight: 250.0, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + alignment: Alignment.topCenter, + children: [ + BlocBuilder( + builder: (context, state) => Positioned.fill( + child: DocumentPreview( + document: state.document, + fit: BoxFit.cover, + ), + ), + ), + Positioned.fill( + top: 0, + child: Container( + height: 100, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withOpacity(0.7), + Colors.black.withOpacity(0.2), + Colors.transparent, + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ], ), ), bottom: ColoredTabBar( @@ -150,7 +184,7 @@ class _DocumentDetailsPageState extends State { children: [ DocumentOverviewWidget( document: state.document, - itemSpacing: _itemPadding, + itemSpacing: _itemSpacing, queryString: widget.titleAndContentQueryString, ), DocumentContentWidget( @@ -161,8 +195,7 @@ class _DocumentDetailsPageState extends State { ), DocumentMetaDataWidget( document: state.document, - itemSpacing: _itemPadding, - metaData: _metaData, + itemSpacing: _itemSpacing, ), const SimilarDocumentsView(), ], @@ -230,13 +263,10 @@ class _DocumentDetailsPageState extends State { ? () => _onDelete(state.document) : null, ).paddedSymmetrically(horizontal: 4), - Tooltip( - message: S.of(context)!.downloadDocumentTooltip, - child: DocumentDownloadButton( - document: state.document, - enabled: isConnected, - metaData: _metaData, - ), + DocumentDownloadButton( + document: state.document, + enabled: isConnected, + metaData: _metaData, ), IconButton( tooltip: S.of(context)!.previewTooltip, @@ -249,14 +279,7 @@ class _DocumentDetailsPageState extends State { icon: const Icon(Icons.open_in_new), onPressed: isConnected ? _onOpenFileInSystemViewer : null, ).paddedOnly(right: 4.0), - IconButton( - tooltip: S.of(context)!.shareTooltip, - icon: const Icon(Icons.share), - onPressed: isConnected - ? () => - context.read().shareDocument() - : null, - ), + DocumentShareButton(document: state.document), ], ); }, diff --git a/lib/features/document_details/view/widgets/archive_serial_number_field.dart b/lib/features/document_details/view/widgets/archive_serial_number_field.dart new file mode 100644 index 00000000..d3597ea5 --- /dev/null +++ b/lib/features/document_details/view/widgets/archive_serial_number_field.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/type/types.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +class ArchiveSerialNumberField extends StatefulWidget { + final DocumentModel document; + const ArchiveSerialNumberField({ + super.key, + required this.document, + }); + + @override + State createState() => + _ArchiveSerialNumberFieldState(); +} + +class _ArchiveSerialNumberFieldState extends State { + late final TextEditingController _asnEditingController; + late bool _showClearButton; + bool _canUpdate = false; + Map _errors = {}; + + @override + void initState() { + super.initState(); + _asnEditingController = TextEditingController( + text: widget.document.archiveSerialNumber?.toString(), + )..addListener(_clearButtonListener); + _showClearButton = widget.document.archiveSerialNumber != null; + } + + void _clearButtonListener() { + setState(() { + _showClearButton = _asnEditingController.text.isNotEmpty; + _canUpdate = int.tryParse(_asnEditingController.text) != + widget.document.archiveSerialNumber; + }); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.document.archiveSerialNumber != + current.document.archiveSerialNumber, + listener: (context, state) { + _asnEditingController.text = + state.document.archiveSerialNumber?.toString() ?? ''; + setState(() { + _canUpdate = false; + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _asnEditingController, + keyboardType: TextInputType.number, + onChanged: (value) { + setState(() => _errors = {}); + }, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onFieldSubmitted: (_) => _onSubmitted(), + decoration: InputDecoration( + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showClearButton) + IconButton( + icon: const Icon(Icons.clear), + color: Theme.of(context).colorScheme.primary, + onPressed: _asnEditingController.clear, + ), + IconButton( + icon: const Icon(Icons.plus_one_rounded), + color: Theme.of(context).colorScheme.primary, + onPressed: + context.watchInternetConnection && !_showClearButton + ? _onAutoAssign + : null, + ).paddedOnly(right: 8), + ], + ), + errorText: _errors['archive_serial_number'], + errorMaxLines: 2, + labelText: S.of(context)!.archiveSerialNumber, + ), + ), + TextButton.icon( + icon: const Icon(Icons.done), + onPressed: context.watchInternetConnection && _canUpdate + ? _onSubmitted + : null, + label: Text(S.of(context)!.save), + ).padded(), + ], + ), + ); + } + + Future _onSubmitted() async { + final value = _asnEditingController.text; + final asn = int.tryParse(value); + + await context + .read() + .assignAsn(widget.document, asn: asn) + .then((value) => _onAsnUpdated()) + .onError( + (error, stackTrace) => showErrorMessage(context, error, stackTrace), + ) + .onError( + (error, stackTrace) => setState(() => _errors = error), + ); + FocusScope.of(context).unfocus(); + } + + Future _onAutoAssign() async { + await context + .read() + .assignAsn( + widget.document, + autoAssign: true, + ) + .then((value) => _onAsnUpdated()) + .onError( + (error, stackTrace) => showErrorMessage(context, error, stackTrace), + ); + } + + void _onAsnUpdated() { + setState(() => _errors = {}); + FocusScope.of(context).unfocus(); + showSnackBar(context, S.of(context)!.archiveSerialNumberUpdated); + } +} diff --git a/lib/features/document_details/view/widgets/document_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 6158e595..654a81cb 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; +import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -34,6 +37,7 @@ class _DocumentDownloadButtonState extends State { @override Widget build(BuildContext context) { return IconButton( + tooltip: S.of(context)!.downloadDocumentTooltip, icon: _isDownloadPending ? const SizedBox( child: CircularProgressIndicator(), @@ -48,25 +52,10 @@ class _DocumentDownloadButtonState extends State { } Future _onDownload(DocumentModel document) async { - final api = context.read(); - final meta = await widget.metaData; try { final downloadOriginal = await showDialog( context: context, - builder: (context) => RadioSettingsDialog( - titleText: S.of(context)!.chooseFiletype, - options: [ - RadioOption( - value: true, - label: S.of(context)!.original + - " (${meta.originalMimeType.split("/").last})"), - RadioOption( - value: false, - label: S.of(context)!.archivedPdf, - ), - ], - initialValue: false, - ), + builder: (context) => const SelectFileTypeDialog(), ); if (downloadOriginal == null) { // Download was cancelled @@ -79,20 +68,14 @@ class _DocumentDownloadButtonState extends State { } } setState(() => _isDownloadPending = true); - final bytes = await api.download( - document, - original: downloadOriginal, - ); - final Directory dir = await FileService.downloadsDirectory; - final fileExtension = - downloadOriginal ? meta.mediaFilename.split(".").last : 'pdf'; - String filePath = "${dir.path}/${meta.mediaFilename}".split(".").first; - filePath += ".$fileExtension"; - final createdFile = File(filePath); - createdFile.createSync(recursive: true); - createdFile.writeAsBytesSync(bytes); - debugPrint("Downloaded file to $filePath"); - showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); + await context.read().downloadDocument( + downloadOriginal: downloadOriginal, + locale: context + .read() + .state + .preferredLocaleSubtag, + ); + // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } catch (error) { diff --git a/lib/features/document_details/view/widgets/document_meta_data_widget.dart b/lib/features/document_details/view/widgets/document_meta_data_widget.dart index 1e78fbb5..6e22a1ab 100644 --- a/lib/features/document_details/view/widgets/document_meta_data_widget.dart +++ b/lib/features/document_details/view/widgets/document_meta_data_widget.dart @@ -2,98 +2,82 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/format_helpers.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; -class DocumentMetaDataWidget extends StatelessWidget { - final Future metaData; +class DocumentMetaDataWidget extends StatefulWidget { final DocumentModel document; final double itemSpacing; const DocumentMetaDataWidget({ super.key, - required this.metaData, required this.document, required this.itemSpacing, }); @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivity) { - return FutureBuilder( - future: metaData, - builder: (context, snapshot) { - if (!connectivity.isConnected && !snapshot.hasData) { - return OfflineWidget(); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } + State createState() => _DocumentMetaDataWidgetState(); +} - final meta = snapshot.data!; - return ListView( - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), +class _DocumentMetaDataWidgetState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + debugPrint("Building state..."); + if (state.metaData == null) { + return const Center(child: CircularProgressIndicator()); + } + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - DetailsItem( - label: S.of(context)!.archiveSerialNumber, - content: document.archiveSerialNumber != null - ? Text(document.archiveSerialNumber.toString()) - : TextButton.icon( - icon: const Icon(Icons.archive_outlined), - label: Text(S.of(context)!.assignAsn), - onPressed: connectivity.isConnected - ? () => _assignAsn(context) - : null, - ), - ).paddedOnly(bottom: itemSpacing), - DetailsItem.text(DateFormat().format(document.modified), - label: S.of(context)!.modifiedAt, context: context) - .paddedOnly(bottom: itemSpacing), - DetailsItem.text(DateFormat().format(document.added), - label: S.of(context)!.addedAt, context: context) - .paddedOnly(bottom: itemSpacing), + ArchiveSerialNumberField( + document: widget.document, + ).paddedOnly(bottom: widget.itemSpacing), + DetailsItem.text( + DateFormat().format(widget.document.modified), + context: context, + label: S.of(context)!.modifiedAt, + ).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text( - meta.mediaFilename, + DateFormat().format(widget.document.added), + context: context, + label: S.of(context)!.addedAt, + ).paddedOnly(bottom: widget.itemSpacing), + DetailsItem.text( + state.metaData!.mediaFilename, context: context, label: S.of(context)!.mediaFilename, - ).paddedOnly(bottom: itemSpacing), + ).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text( - meta.originalChecksum, + state.metaData!.originalChecksum, context: context, label: S.of(context)!.originalMD5Checksum, - ).paddedOnly(bottom: itemSpacing), - DetailsItem.text(formatBytes(meta.originalSize, 2), - label: S.of(context)!.originalFileSize, - context: context) - .paddedOnly(bottom: itemSpacing), + ).paddedOnly(bottom: widget.itemSpacing), DetailsItem.text( - meta.originalMimeType, - label: S.of(context)!.originalMIMEType, + formatBytes(state.metaData!.originalSize, 2), + context: context, + label: S.of(context)!.originalFileSize, + ).paddedOnly(bottom: widget.itemSpacing), + DetailsItem.text( + state.metaData!.originalMimeType, context: context, - ).paddedOnly(bottom: itemSpacing), + label: S.of(context)!.originalMIMEType, + ).paddedOnly(bottom: widget.itemSpacing), ], - ); - }, + ), + ), ); }, ); } - - Future _assignAsn(BuildContext context) async { - try { - await context.read().assignAsn(document); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } } diff --git a/lib/features/document_details/view/widgets/document_share_button.dart b/lib/features/document_details/view/widgets/document_share_button.dart new file mode 100644 index 00000000..a914a75c --- /dev/null +++ b/lib/features/document_details/view/widgets/document_share_button.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/constants.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/helpers/permission_helpers.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; + +class DocumentShareButton extends StatefulWidget { + final DocumentModel? document; + final bool enabled; + const DocumentShareButton({ + super.key, + required this.document, + this.enabled = true, + }); + + @override + State createState() => _DocumentShareButtonState(); +} + +class _DocumentShareButtonState extends State { + bool _isDownloadPending = false; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: S.of(context)!.shareTooltip, + icon: _isDownloadPending + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.share), + onPressed: widget.document != null && widget.enabled + ? () => _onShare(widget.document!) + : null, + ).paddedOnly(right: 4); + } + + Future _onShare(DocumentModel document) async { + try { + final shareOriginal = await showDialog( + context: context, + builder: (context) => const SelectFileTypeDialog(), + ); + if (shareOriginal == null) { + // Download was cancelled + return; + } + if (Platform.isAndroid && androidInfo!.version.sdkInt! < 30) { + final isGranted = await askForPermission(Permission.storage); + if (!isGranted) { + return; + } + } + setState(() => _isDownloadPending = true); + await context + .read() + .shareDocument(shareOriginal: shareOriginal); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } catch (error) { + showGenericError(context, error); + } finally { + if (mounted) { + setState(() => _isDownloadPending = false); + } + } + } +} diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index a4a210e2..4c5687c8 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -38,6 +38,7 @@ class _DocumentEditPageState extends State { static const fkDocumentType = "documentType"; static const fkCreatedDate = "createdAtDate"; static const fkStoragePath = 'storagePath'; + static const fkContent = 'content'; final GlobalKey _formKey = GlobalKey(); bool _isSubmitLoading = false; @@ -55,94 +56,131 @@ class _DocumentEditPageState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Scaffold( - resizeToAvoidBottomInset: false, - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _onSubmit(state.document), - icon: const Icon(Icons.save), - label: Text(S.of(context)!.saveChanges), - ), - appBar: AppBar( - title: Text(S.of(context)!.editDocument), - bottom: _isSubmitLoading - ? const PreferredSize( - preferredSize: Size.fromHeight(4), - child: LinearProgressIndicator(), - ) - : null, - ), - extendBody: true, - body: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - top: 8, - left: 8, - right: 8, + return DefaultTabController( + length: 2, + child: Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _onSubmit(state.document), + icon: const Icon(Icons.save), + label: Text(S.of(context)!.saveChanges), ), - child: FormBuilder( - key: _formKey, - child: ListView( - children: [ - _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField(state.document.created).padded(), - _buildCorrespondentFormField( - state.document.correspondent, - state.correspondents, - ).padded(), - _buildDocumentTypeFormField( - state.document.documentType, - state.documentTypes, - ).padded(), - _buildStoragePathFormField( - state.document.storagePath, - state.storagePaths, - ).padded(), - TagFormField( - initialValue: - IdsTagsQuery.included(state.document.tags.toList()), - notAssignedSelectable: false, - anyAssignedSelectable: false, - excludeAllowed: false, - name: fkTags, - selectableOptions: state.tags, - suggestions: _filteredSuggestions.tags - .toSet() - .difference(state.document.tags.toSet()) - .isNotEmpty - ? _buildSuggestionsSkeleton( - suggestions: _filteredSuggestions.tags, - itemBuilder: (context, itemData) { - final tag = state.tags[itemData]!; - return ActionChip( - label: Text( - tag.name, - style: TextStyle(color: tag.textColor), - ), - backgroundColor: tag.color, - onPressed: () { - final currentTags = _formKey.currentState - ?.fields[fkTags]?.value as TagsQuery; - if (currentTags is IdsTagsQuery) { - _formKey.currentState?.fields[fkTags] - ?.didChange((IdsTagsQuery.fromIds( - {...currentTags.ids, itemData}))); - } else { - _formKey.currentState?.fields[fkTags] - ?.didChange((IdsTagsQuery.fromIds( - {itemData}))); - } - }, - ); - }, - ) - : null, - ).padded(), - const SizedBox( - height: 64), // Prevent tags from being hidden by fab + appBar: AppBar( + title: Text(S.of(context)!.editDocument), + bottom: TabBar( + tabs: [ + Tab( + text: S.of(context)!.overview, + ), + Tab( + text: S.of(context)!.content, + ) ], ), ), - )); + extendBody: true, + body: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + top: 8, + left: 8, + right: 8, + ), + child: FormBuilder( + key: _formKey, + child: TabBarView( + children: [ + ListView( + children: [ + _buildTitleFormField(state.document.title).padded(), + _buildCreatedAtFormField(state.document.created) + .padded(), + _buildCorrespondentFormField( + state.document.correspondent, + state.correspondents, + ).padded(), + _buildDocumentTypeFormField( + state.document.documentType, + state.documentTypes, + ).padded(), + _buildStoragePathFormField( + state.document.storagePath, + state.storagePaths, + ).padded(), + TagFormField( + initialValue: IdsTagsQuery.included( + state.document.tags.toList()), + notAssignedSelectable: false, + anyAssignedSelectable: false, + excludeAllowed: false, + name: fkTags, + selectableOptions: state.tags, + suggestions: _filteredSuggestions.tags + .toSet() + .difference(state.document.tags.toSet()) + .isNotEmpty + ? _buildSuggestionsSkeleton( + suggestions: _filteredSuggestions.tags, + itemBuilder: (context, itemData) { + final tag = state.tags[itemData]!; + return ActionChip( + label: Text( + tag.name, + style: + TextStyle(color: tag.textColor), + ), + backgroundColor: tag.color, + onPressed: () { + final currentTags = _formKey + .currentState + ?.fields[fkTags] + ?.value as TagsQuery; + if (currentTags is IdsTagsQuery) { + _formKey + .currentState?.fields[fkTags] + ?.didChange( + (IdsTagsQuery.fromIds({ + ...currentTags.ids, + itemData + }))); + } else { + _formKey + .currentState?.fields[fkTags] + ?.didChange( + (IdsTagsQuery.fromIds( + {itemData}))); + } + }, + ); + }, + ) + : null, + ).padded(), + // Prevent tags from being hidden by fab + const SizedBox(height: 64), + ], + ), + SingleChildScrollView( + child: Column( + children: [ + FormBuilderTextField( + name: fkContent, + maxLines: null, + keyboardType: TextInputType.multiline, + initialValue: state.document.content, + decoration: const InputDecoration( + border: InputBorder.none, + ), + ), + const SizedBox(height: 84), + ], + ), + ), + ], + ), + ), + )), + ); }, ); } @@ -238,13 +276,13 @@ class _DocumentEditPageState extends State { if (_formKey.currentState?.saveAndValidate() ?? false) { final values = _formKey.currentState!.value; var mergedDocument = document.copyWith( - title: values[fkTitle], - created: values[fkCreatedDate], - documentType: () => (values[fkDocumentType] as IdQueryParameter).id, - correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id, - storagePath: () => (values[fkStoragePath] as IdQueryParameter).id, - tags: (values[fkTags] as IdsTagsQuery).includedIds, - ); + title: values[fkTitle], + created: values[fkCreatedDate], + documentType: () => (values[fkDocumentType] as IdQueryParameter).id, + correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id, + storagePath: () => (values[fkStoragePath] as IdQueryParameter).id, + tags: (values[fkTags] as IdsTagsQuery).includedIds, + content: values[fkContent]); setState(() { _isSubmitLoading = true; }); diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index e9ce8d91..c3642c45 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -217,7 +217,7 @@ class InboxCubit extends HydratedCubit if (document.archiveSerialNumber == null) { final int asn = await _documentsApi.findNextAsn(); final updatedDocument = await _documentsApi - .update(document.copyWith(archiveSerialNumber: asn)); + .update(document.copyWith(archiveSerialNumber: () => asn)); replace(updatedDocument); } diff --git a/lib/features/notifications/converters/notification_tap_response_payload.dart b/lib/features/notifications/converters/notification_tap_response_payload.dart new file mode 100644 index 00000000..93e06aee --- /dev/null +++ b/lib/features/notifications/converters/notification_tap_response_payload.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart'; + +class NotificationTapResponsePayloadConverter + implements + JsonConverter> { + const NotificationTapResponsePayloadConverter(); + @override + NotificationTapResponsePayload fromJson(Map json) { + final type = NotificationResponseOpenAction.values.byName(json['type']); + switch (type) { + case NotificationResponseOpenAction.openDownloadedDocumentPath: + return OpenDownloadedDocumentPayload.fromJson( + json, + ); + } + } + + @override + Map toJson(NotificationTapResponsePayload object) { + return object.toJson(); + } +} diff --git a/lib/features/notifications/models/notification_actions.dart b/lib/features/notifications/models/notification_actions.dart new file mode 100644 index 00000000..f7f6662a --- /dev/null +++ b/lib/features/notifications/models/notification_actions.dart @@ -0,0 +1,11 @@ +import 'package:json_annotation/json_annotation.dart'; + +enum NotificationResponseButtonAction { + openCreatedDocument, + acknowledgeCreatedDocument; +} + +@JsonEnum() +enum NotificationResponseOpenAction { + openDownloadedDocumentPath; +} diff --git a/lib/features/notifications/services/notification_channels.dart b/lib/features/notifications/models/notification_channels.dart similarity index 51% rename from lib/features/notifications/services/notification_channels.dart rename to lib/features/notifications/models/notification_channels.dart index dea02142..3b8c4318 100644 --- a/lib/features/notifications/services/notification_channels.dart +++ b/lib/features/notifications/models/notification_channels.dart @@ -1,5 +1,6 @@ enum NotificationChannel { - task("task_channel", "Paperless Tasks"); + task("task_channel", "Paperless tasks"), + documentDownload("document_download_channel", "Document downloads"); final String id; final String name; diff --git a/lib/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart b/lib/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart new file mode 100644 index 00000000..61ca2463 --- /dev/null +++ b/lib/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart @@ -0,0 +1,15 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'create_document_success_payload.g.dart'; + +@JsonSerializable() +class CreateDocumentSuccessPayload { + final int documentId; + + CreateDocumentSuccessPayload(this.documentId); + + factory CreateDocumentSuccessPayload.fromJson(Map json) => + _$CreateDocumentSuccessPayloadFromJson(json); + + Map toJson() => _$CreateDocumentSuccessPayloadToJson(this); +} diff --git a/lib/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart b/lib/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart new file mode 100644 index 00000000..7df7f77b --- /dev/null +++ b/lib/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart @@ -0,0 +1,8 @@ +import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; + +abstract class NotificationTapResponsePayload { + final NotificationResponseOpenAction type; + + Map toJson(); + NotificationTapResponsePayload({required this.type}); +} diff --git a/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart b/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart new file mode 100644 index 00000000..6612a139 --- /dev/null +++ b/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart'; + +part 'open_downloaded_document_payload.g.dart'; + +@JsonSerializable() +class OpenDownloadedDocumentPayload extends NotificationTapResponsePayload { + final String filePath; + OpenDownloadedDocumentPayload({ + required this.filePath, + super.type = NotificationResponseOpenAction.openDownloadedDocumentPath, + }); + + factory OpenDownloadedDocumentPayload.fromJson(Map json) => + _$OpenDownloadedDocumentPayloadFromJson(json); + @override + Map toJson() => _$OpenDownloadedDocumentPayloadToJson(this); +} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index 2d85814b..97d0cff7 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -2,11 +2,16 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart'; -import 'package:paperless_mobile/features/notifications/services/notification_actions.dart'; -import 'package:paperless_mobile/features/notifications/services/notification_channels.dart'; +import 'package:paperless_mobile/features/notifications/converters/notification_tap_response_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_channels.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class LocalNotificationService { final FlutterLocalNotificationsPlugin _plugin = @@ -16,7 +21,7 @@ class LocalNotificationService { Future initialize() async { const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('ic_stat_paperless_logo_green'); + AndroidInitializationSettings('paperless_logo_green'); final DarwinInitializationSettings initializationSettingsDarwin = DarwinInitializationSettings( requestSoundPermission: false, @@ -32,6 +37,8 @@ class LocalNotificationService { await _plugin.initialize( initializationSettings, onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + onDidReceiveBackgroundNotificationResponse: + onDidReceiveBackgroundNotificationResponse, ); await _plugin .resolvePlatformSpecificImplementation< @@ -39,6 +46,51 @@ class LocalNotificationService { ?.requestPermission(); } + Future notifyFileDownload({ + required DocumentModel document, + required String filename, + required String filePath, + required bool finished, + required String locale, + }) async { + final tr = await S.delegate.load(Locale(locale)); + + int id = document.id; + await _plugin.show( + id, + filename, + finished + ? tr.notificationDownloadComplete + : tr.notificationDownloadingDocument, + NotificationDetails( + android: AndroidNotificationDetails( + NotificationChannel.documentDownload.id + "_${document.id}", + NotificationChannel.documentDownload.name, + ongoing: !finished, + indeterminate: true, + importance: Importance.max, + priority: Priority.high, + showProgress: !finished, + when: DateTime.now().millisecondsSinceEpoch, + category: AndroidNotificationCategory.progress, + icon: finished ? 'file_download_done' : 'downloading', + ), + iOS: DarwinNotificationDetails( + attachments: [ + DarwinNotificationAttachment( + filePath, + ), + ], + ), + ), + payload: jsonEncode( + OpenDownloadedDocumentPayload( + filePath: filePath, + ).toJson(), + ), + ); //TODO: INTL + } + //TODO: INTL Future notifyTaskChanged(Task task) { log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); @@ -49,20 +101,17 @@ class LocalNotificationService { late int timestampMillis; bool showProgress = status == TaskStatus.started || status == TaskStatus.pending; - int progress = 0; dynamic payload; switch (status) { case TaskStatus.started: title = "Document received"; body = task.taskFileName; timestampMillis = task.dateCreated.millisecondsSinceEpoch; - progress = 10; break; case TaskStatus.pending: title = "Processing document..."; body = task.taskFileName; timestampMillis = task.dateCreated.millisecondsSinceEpoch; - progress = 70; break; case TaskStatus.failure: title = "Failed to process document"; @@ -73,7 +122,7 @@ class LocalNotificationService { title = "Document successfully created"; body = task.taskFileName; timestampMillis = task.dateDone!.millisecondsSinceEpoch; - payload = CreateDocumentSuccessNotificationResponsePayload( + payload = CreateDocumentSuccessPayload( task.relatedDocument!, ); break; @@ -93,7 +142,7 @@ class LocalNotificationService { showProgress: showProgress, maxProgress: 100, when: timestampMillis, - progress: progress, + indeterminate: true, actions: status == TaskStatus.success ? [ //TODO: Implement once moved to new routing @@ -109,6 +158,7 @@ class LocalNotificationService { ] : [], ), + //TODO: Add darwin support ), payload: jsonEncode(payload), ); @@ -119,38 +169,68 @@ class LocalNotificationService { String? title, String? body, String? payload, - ) {} + ) { + debugPrint("onDidReceiveNotification!"); + } void onDidReceiveNotificationResponse(NotificationResponse response) { - debugPrint("Received Notification: ${response.payload}"); - if (response.notificationResponseType == - NotificationResponseType.selectedNotificationAction) { - final action = - NotificationResponseAction.values.byName(response.actionId!); - _handleResponseAction(action, response); + debugPrint( + "Received Notification ${response.id}: Action is ${response.actionId}): ${response.payload}", + ); + switch (response.notificationResponseType) { + case NotificationResponseType.selectedNotification: + if (response.payload != null) { + final payload = + const NotificationTapResponsePayloadConverter().fromJson( + jsonDecode(response.payload!), + ); + _handleResponseTapAction(payload.type, response); + } + + break; + case NotificationResponseType.selectedNotificationAction: + final action = + NotificationResponseButtonAction.values.byName(response.actionId!); + _handleResponseButtonAction(action, response); + break; } - // Non-actionable notification pressed, ignoring... } - void _handleResponseAction( - NotificationResponseAction action, + void _handleResponseButtonAction( + NotificationResponseButtonAction action, NotificationResponse response, ) { switch (action) { - case NotificationResponseAction.openCreatedDocument: - final payload = - CreateDocumentSuccessNotificationResponsePayload.fromJson( + case NotificationResponseButtonAction.openCreatedDocument: + final payload = CreateDocumentSuccessPayload.fromJson( jsonDecode(response.payload!), ); log("Navigate to document ${payload.documentId}"); break; - case NotificationResponseAction.acknowledgeCreatedDocument: - final payload = - CreateDocumentSuccessNotificationResponsePayload.fromJson( + case NotificationResponseButtonAction.acknowledgeCreatedDocument: + final payload = CreateDocumentSuccessPayload.fromJson( jsonDecode(response.payload!), ); log("Acknowledge document ${payload.documentId}"); break; } } + + void _handleResponseTapAction( + NotificationResponseOpenAction type, + NotificationResponse response, + ) { + switch (type) { + case NotificationResponseOpenAction.openDownloadedDocumentPath: + final payload = OpenDownloadedDocumentPayload.fromJson( + jsonDecode(response.payload!)); + OpenFilex.open(payload.filePath); + break; + } + } +} + +void onDidReceiveBackgroundNotificationResponse(NotificationResponse response) { + //TODO: When periodic background inbox check is implemented, notification tap is handled here + debugPrint(response.toString()); } diff --git a/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart b/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart deleted file mode 100644 index 3c7999a5..00000000 --- a/lib/features/notifications/services/models/notification_payloads/open_created_document_notification_payload.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'open_created_document_notification_payload.g.dart'; - -@JsonSerializable() -class CreateDocumentSuccessNotificationResponsePayload { - final int documentId; - - CreateDocumentSuccessNotificationResponsePayload(this.documentId); - - factory CreateDocumentSuccessNotificationResponsePayload.fromJson( - Map json) => - _$CreateDocumentSuccessNotificationResponsePayloadFromJson(json); - - Map toJson() => - _$CreateDocumentSuccessNotificationResponsePayloadToJson(this); -} diff --git a/lib/features/notifications/services/notification_actions.dart b/lib/features/notifications/services/notification_actions.dart deleted file mode 100644 index 91717c07..00000000 --- a/lib/features/notifications/services/notification_actions.dart +++ /dev/null @@ -1,4 +0,0 @@ -enum NotificationResponseAction { - openCreatedDocument, - acknowledgeCreatedDocument; -} diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 0a0f483c..52655598 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -677,5 +677,21 @@ "dynamicColorScheme": "Dynamicky", "@dynamicColorScheme": {}, "classicColorScheme": "Klasicky", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download complete", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Downloading document", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Buy me a coffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 5a9848ac..5385592f 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -677,5 +677,21 @@ "dynamicColorScheme": "Dynamisch", "@dynamicColorScheme": {}, "classicColorScheme": "Klassisch", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download abgeschlossen", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Dokument wird heruntergeladen", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archiv-Seriennummer aktualisiert.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Spendiere mir einen Kaffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 374c0914..162bd8e7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -677,5 +677,21 @@ "dynamicColorScheme": "Dynamic", "@dynamicColorScheme": {}, "classicColorScheme": "Classic", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download complete", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Downloading document", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Buy me a coffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 6560deaf..cf3d97dc 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -516,15 +516,15 @@ "@password": {}, "passwordMustNotBeEmpty": "Le mot de passe ne doit pas être vide.", "@passwordMustNotBeEmpty": {}, - "connectionTimedOut": "La connection a expiré.", + "connectionTimedOut": "La connexion a expiré.", "@connectionTimedOut": {}, - "loginPageReachabilityMissingClientCertificateText": "Un certificat client était attendu mais n'a pas été envoyé. Veuillez fournir un certificat.", + "loginPageReachabilityMissingClientCertificateText": "Un certificat client était attendu, mais n'a pas été envoyé. Veuillez fournir un certificat.", "@loginPageReachabilityMissingClientCertificateText": {}, - "couldNotEstablishConnectionToTheServer": "Impossible d'établir la connection jusqu'au serveur.", + "couldNotEstablishConnectionToTheServer": "Impossible d'établir la connexion jusqu'au serveur.", "@couldNotEstablishConnectionToTheServer": {}, - "connectionSuccessfulylEstablished": "Connection établie avec succès.", + "connectionSuccessfulylEstablished": "Connexion établie avec succès.", "@connectionSuccessfulylEstablished": {}, - "hostCouldNotBeResolved": "L'hôte ne peut pas être résolu. Veuillez vérifier l'addresse du serveur et votre connection internet. ", + "hostCouldNotBeResolved": "L'hôte ne peut pas être résolu. Veuillez vérifier l'adresse du serveur et votre connexion internet. ", "@hostCouldNotBeResolved": {}, "serverAddress": "Adresse du Serveur", "@serverAddress": {}, @@ -532,7 +532,7 @@ "@invalidAddress": {}, "serverAddressMustIncludeAScheme": "L'adresse du serveur doit respecter le schéma.", "@serverAddressMustIncludeAScheme": {}, - "serverAddressMustNotBeEmpty": "L'addresse du serveur ne doit pas être vide.", + "serverAddressMustNotBeEmpty": "L'adresse du serveur ne doit pas être vide.", "@serverAddressMustNotBeEmpty": {}, "signIn": "Se connecter", "@signIn": {}, @@ -574,7 +574,7 @@ "@documentMatchesThisRegularExpression": {}, "regularExpression": "Expression Régulière", "@regularExpression": {}, - "anInternetConnectionCouldNotBeEstablished": "Impossible d'établir une connection internet.", + "anInternetConnectionCouldNotBeEstablished": "Impossible d'établir une connexion internet.", "@anInternetConnectionCouldNotBeEstablished": {}, "done": "Fait", "@done": {}, @@ -622,7 +622,7 @@ "@languageAndVisualAppearance": {}, "applicationSettings": "Application", "@applicationSettings": {}, - "colorSchemeHint": "Choisissez entre une palette de couleurs inspirée par le vert Paperless traditionne, ou utilisez la palette de couleur dynamique basée sur le thème système.", + "colorSchemeHint": "Choisissez entre une palette de couleurs inspirée par le vert Paperless traditionnel, ou utilisez la palette de couleur dynamique basée sur le thème système.", "@colorSchemeHint": {}, "colorSchemeNotSupportedWarning": "Le thème dynamique n'est supporté que sur les appareils sous Android 12 ou plus. Sélectionner l'option 'Dynamique' pourrait ne pas avoir d'effet en fonction de l'implémentation de votre système d'exploitation.", "@colorSchemeNotSupportedWarning": {}, @@ -673,9 +673,25 @@ "list": "Liste", "@list": {}, "remove": "Retirer", - "removeQueryFromSearchHistory": "Retirer la requête de l'historique de recherche ?", + "removeQueryFromSearchHistory": "Retirer la requête de l'historique de recherche ?", "dynamicColorScheme": "Dynamique", "@dynamicColorScheme": {}, "classicColorScheme": "Classique", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download complete", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Downloading document", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Buy me a coffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 1120ad69..602dfeb2 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -677,5 +677,21 @@ "dynamicColorScheme": "Dynamic", "@dynamicColorScheme": {}, "classicColorScheme": "Classic", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download complete", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Downloading document", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Buy me a coffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 8774be32..60329864 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -677,5 +677,21 @@ "dynamicColorScheme": "Dynamic", "@dynamicColorScheme": {}, "classicColorScheme": "Classic", - "@classicColorScheme": {} + "@classicColorScheme": {}, + "notificationDownloadComplete": "Download complete", + "@notificationDownloadComplete": { + "description": "Notification title when a download has been completed." + }, + "notificationDownloadingDocument": "Downloading document", + "@notificationDownloadingDocument": { + "description": "Notification title shown when a document download is pending" + }, + "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "@archiveSerialNumberUpdated": { + "description": "Message shown when the ASN has been updated." + }, + "donateCoffee": "Buy me a coffee", + "@donateCoffee": { + "description": "Label displayed in the app drawer" + } } \ No newline at end of file diff --git a/lib/routes/document_details_route.dart b/lib/routes/document_details_route.dart index 544c8bc4..7bde4984 100644 --- a/lib/routes/document_details_route.dart +++ b/lib/routes/document_details_route.dart @@ -16,6 +16,7 @@ class DocumentDetailsRoute extends StatelessWidget { return BlocProvider( create: (context) => DocumentDetailsCubit( + context.read(), context.read(), context.read(), initialDocument: args.document, diff --git a/packages/paperless_api/lib/src/models/document_model.dart b/packages/paperless_api/lib/src/models/document_model.dart index 42e96098..801778fc 100644 --- a/packages/paperless_api/lib/src/models/document_model.dart +++ b/packages/paperless_api/lib/src/models/document_model.dart @@ -76,7 +76,7 @@ class DocumentModel extends Equatable { DateTime? created, DateTime? modified, DateTime? added, - int? archiveSerialNumber, + int? Function()? archiveSerialNumber, String? originalFileName, String? archivedFileName, }) { @@ -84,17 +84,18 @@ class DocumentModel extends Equatable { id: id, title: title ?? this.title, content: content ?? this.content, - documentType: - documentType != null ? documentType.call() : this.documentType, + documentType: documentType != null ? documentType() : this.documentType, correspondent: - correspondent != null ? correspondent.call() : this.correspondent, - storagePath: storagePath != null ? storagePath.call() : this.storagePath, + correspondent != null ? correspondent() : this.correspondent, + storagePath: storagePath != null ? storagePath() : this.storagePath, tags: tags ?? this.tags, created: created ?? this.created, modified: modified ?? this.modified, added: added ?? this.added, originalFileName: originalFileName ?? this.originalFileName, - archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber, + archiveSerialNumber: archiveSerialNumber != null + ? archiveSerialNumber() + : this.archiveSerialNumber, archivedFileName: archivedFileName ?? this.archivedFileName, ); } 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 ce6f8cfd..83f256ed 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 @@ -24,6 +24,12 @@ abstract class PaperlessDocumentsApi { Future getPreview(int docId); String getThumbnailUrl(int docId); Future download(DocumentModel document, {bool original}); + Future downloadToFile( + DocumentModel document, + String localFilePath, { + bool original = false, + void Function(double)? onProgressChanged, + }); 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 eafbf71a..e609e9a5 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 @@ -199,7 +199,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { try { final response = await client.get( "/api/documents/${document.id}/download/", - queryParameters: original ? {'original': true} : {}, + queryParameters: {'original': original}, options: Options(responseType: ResponseType.bytes), ); return response.data; @@ -208,6 +208,27 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } } + @override + Future downloadToFile( + DocumentModel document, + String localFilePath, { + bool original = false, + void Function(double)? onProgressChanged, + }) async { + try { + final response = await client.download( + "/api/documents/${document.id}/download/", + localFilePath, + onReceiveProgress: (count, total) => + onProgressChanged?.call(count / total), + queryParameters: {'original': original}, + ); + return response.data; + } on DioError catch (err) { + throw err.error!; + } + } + @override Future getMetaData(DocumentModel document) async { try { diff --git a/pubspec.lock b/pubspec.lock index fe5cf803..c604197a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -824,6 +824,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" + in_app_review: + dependency: "direct main" + description: + name: in_app_review + sha256: "16328b8202d36522322b95804ae5d975577aa9f584d634985849ba1099645850" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: b12ec9aaf6b34d3a72aa95895eb252b381896246bdad4ef378d444affe8410ef + url: "https://pub.dev" + source: hosted + version: "2.0.4" integration_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 0d9ca476..e3d27404 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: flutter_displaymode: ^0.5.0 dynamic_color: ^1.5.4 flutter_html: ^3.0.0-alpha.6 + in_app_review: ^2.0.6 dev_dependencies: integration_test: From c7da398c535d121d34a20874199784137de2b710 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 7 Mar 2023 00:23:23 +0100 Subject: [PATCH 2/2] fix: Fix download and share --- .../cubit/document_details_cubit.dart | 36 ++++++++++++++----- .../widgets/document_download_button.dart | 5 ++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 7c8e90b3..026b1f47 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -78,22 +78,27 @@ class DocumentDetailsCubit extends Cubit { Future openDocumentInSystemViewer() async { final cacheDir = await FileService.temporaryDirectory; - + await FileService.clearDirectoryContent(PaperlessDirectoryType.temporary); if (state.metaData == null) { await loadMetaData(); } + final desc = FileDescription.fromPath( + state.metaData!.mediaFilename.replaceAll("/", " ")); - await _api.downloadToFile( - state.document, - '${cacheDir.path}/${state.metaData!.mediaFilename}', - ); + final fileName = "${desc.filename}.pdf"; + final file = File("${cacheDir.path}/$fileName"); + if (!file.existsSync()) { + file.createSync(); + await _api.downloadToFile( + state.document, + file.path, + ); + } return OpenFilex.open( - '${cacheDir.path}/${state.metaData!.mediaFilename}', + file.path, type: "application/pdf", - ).then( - (value) => value.type, - ); + ).then((value) => value.type); } void replace(DocumentModel document) { @@ -115,6 +120,18 @@ class DocumentDetailsCubit extends Cubit { state.metaData!.mediaFilename .replaceAll("/", " "), // Flatten directory structure ); + if (!File(filePath).existsSync()) { + File(filePath).createSync(); + } else { + return _notificationService.notifyFileDownload( + document: state.document, + filename: "${desc.filename}.${desc.extension}", + filePath: filePath, + finished: true, + locale: locale, + ); + } + await _notificationService.notifyFileDownload( document: state.document, filename: "${desc.filename}.${desc.extension}", @@ -122,6 +139,7 @@ class DocumentDetailsCubit extends Cubit { finished: false, locale: locale, ); + await _api.downloadToFile( state.document, filePath, diff --git a/lib/features/document_details/view/widgets/document_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 654a81cb..63d98aec 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -2,12 +2,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; -import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -61,10 +59,11 @@ class _DocumentDownloadButtonState extends State { // Download was cancelled return; } - if (Platform.isAndroid && androidInfo!.version.sdkInt! < 30) { + if (Platform.isAndroid && androidInfo!.version.sdkInt! <= 29) { final isGranted = await askForPermission(Permission.storage); if (!isGranted) { return; + //TODO: Tell user to grant permissions } } setState(() => _isDownloadPending = true);