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: