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 00000000..993bbef8 Binary files /dev/null and b/android/app/src/main/res/drawable/downloading.png differ diff --git a/android/app/src/main/res/drawable/file_download_done.png b/android/app/src/main/res/drawable/file_download_done.png new file mode 100644 index 00000000..1e1ca2d1 Binary files /dev/null and b/android/app/src/main/res/drawable/file_download_done.png differ diff --git a/android/app/src/main/res/drawable/paperless_logo_green.png b/android/app/src/main/res/drawable/paperless_logo_green.png new file mode 100644 index 00000000..4a6f7f7c Binary files /dev/null and b/android/app/src/main/res/drawable/paperless_logo_green.png differ diff --git a/assets/images/bmc-logo.svg b/assets/images/bmc-logo.svg new file mode 100644 index 00000000..5ba6db99 --- /dev/null +++ b/assets/images/bmc-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + 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..026b1f47 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,43 +58,120 @@ 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); } } 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("/", " ")); - final metaData = await _api.getMetaData(state.document); - final bytes = await _api.download(state.document); - - final file = File('${cacheDir.path}/${metaData.mediaFilename}') - ..createSync(recursive: true) - ..writeAsBytesSync(bytes); + final fileName = "${desc.filename}.pdf"; + final file = File("${cacheDir.path}/$fileName"); - return OpenFilex.open(file.path, type: "application/pdf").then( - (value) => value.type, - ); + if (!file.existsSync()) { + file.createSync(); + await _api.downloadToFile( + state.document, + file.path, + ); + } + return OpenFilex.open( + file.path, + type: "application/pdf", + ).then((value) => value.type); } void replace(DocumentModel document) { 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 + ); + 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}", + 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 +181,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..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,9 +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/settings/view/widgets/radio_settings_dialog.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/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -34,6 +35,7 @@ class _DocumentDownloadButtonState extends State { @override Widget build(BuildContext context) { return IconButton( + tooltip: S.of(context)!.downloadDocumentTooltip, icon: _isDownloadPending ? const SizedBox( child: CircularProgressIndicator(), @@ -48,51 +50,31 @@ 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 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); - 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: