feat: Improve notifications, add donation button, improved asn form field

This commit is contained in:
Anton Stubenbord
2023-03-06 22:21:14 +01:00
parent bd891a8658
commit 1172e54199
37 changed files with 985 additions and 305 deletions

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
@@ -45,6 +46,13 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
} }
} }
extension ConnectivityFromContext on BuildContext {
bool get watchInternetConnection =>
watch<ConnectivityCubit>().state.isConnected;
bool get readInternetConnection =>
read<ConnectivityCubit>().state.isConnected;
}
enum ConnectivityState { enum ConnectivityState {
connected, connected,
notConnected, notConnected,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/constants.dart';
import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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'); '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( ListTile(
dense: true, dense: true,
leading: const Icon(Icons.settings_outlined), leading: const Icon(Icons.settings_outlined),

View File

@@ -3,10 +3,13 @@ import 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.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/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:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -15,15 +18,18 @@ part 'document_details_state.dart';
class DocumentDetailsCubit extends Cubit<DocumentDetailsState> { class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
final PaperlessDocumentsApi _api; final PaperlessDocumentsApi _api;
final DocumentChangedNotifier _notifier; final DocumentChangedNotifier _notifier;
final LocalNotificationService _notificationService;
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
DocumentDetailsCubit( DocumentDetailsCubit(
this._api, this._api,
this._notifier, { this._notifier,
this._notificationService, {
required DocumentModel initialDocument, required DocumentModel initialDocument,
}) : super(DocumentDetailsState(document: initialDocument)) { }) : super(DocumentDetailsState(document: initialDocument)) {
_notifier.subscribe(this, onUpdated: replace); _notifier.subscribe(this, onUpdated: replace);
loadSuggestions(); loadSuggestions();
loadMetaData();
} }
Future<void> delete(DocumentModel document) async { Future<void> delete(DocumentModel document) async {
@@ -36,6 +42,11 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
emit(state.copyWith(suggestions: suggestions)); emit(state.copyWith(suggestions: suggestions));
} }
Future<void> loadMetaData() async {
final metaData = await _api.getMetaData(state.document);
emit(state.copyWith(metaData: metaData));
}
Future<void> loadFullContent() async { Future<void> loadFullContent() async {
final doc = await _api.find(state.document.id); final doc = await _api.find(state.document.id);
if (doc == null) { if (doc == null) {
@@ -47,11 +58,20 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
)); ));
} }
Future<void> assignAsn(DocumentModel document) async { Future<void> assignAsn(
if (document.archiveSerialNumber == null) { DocumentModel document, {
final int asn = await _api.findNextAsn(); int? asn,
final updatedDocument = bool autoAssign = false,
await _api.update(document.copyWith(archiveSerialNumber: asn)); }) 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); _notifier.notifyUpdated(updatedDocument);
} }
} }
@@ -59,14 +79,19 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
Future<ResultType> openDocumentInSystemViewer() async { Future<ResultType> openDocumentInSystemViewer() async {
final cacheDir = await FileService.temporaryDirectory; final cacheDir = await FileService.temporaryDirectory;
final metaData = await _api.getMetaData(state.document); if (state.metaData == null) {
final bytes = await _api.download(state.document); await loadMetaData();
}
final file = File('${cacheDir.path}/${metaData.mediaFilename}') await _api.downloadToFile(
..createSync(recursive: true) state.document,
..writeAsBytesSync(bytes); '${cacheDir.path}/${state.metaData!.mediaFilename}',
);
return OpenFilex.open(file.path, type: "application/pdf").then( return OpenFilex.open(
'${cacheDir.path}/${state.metaData!.mediaFilename}',
type: "application/pdf",
).then(
(value) => value.type, (value) => value.type,
); );
} }
@@ -75,15 +100,60 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
emit(state.copyWith(document: document)); emit(state.copyWith(document: document));
} }
Future<void> shareDocument() async { Future<void> downloadDocument({
final documentBytes = await _api.download(state.document); bool downloadOriginal = false,
final dir = await getTemporaryDirectory(); required String locale,
final String path = "${dir.path}/${state.document.originalFileName}"; }) async {
await File(path).writeAsBytes(documentBytes); if (state.metaData == null) {
await loadMetaData();
}
String filePath = _buildDownloadFilePath(
downloadOriginal,
await FileService.downloadsDirectory,
);
final desc = FileDescription.fromPath(
state.metaData!.mediaFilename
.replaceAll("/", " "), // Flatten directory structure
);
await _notificationService.notifyFileDownload(
document: state.document,
filename: "${desc.filename}.${desc.extension}",
filePath: filePath,
finished: false,
locale: locale,
);
await _api.downloadToFile(
state.document,
filePath,
original: downloadOriginal,
);
await _notificationService.notifyFileDownload(
document: state.document,
filename: "${desc.filename}.${desc.extension}",
filePath: filePath,
finished: true,
locale: locale,
);
debugPrint("Downloaded file to $filePath");
}
Future<void> 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( Share.shareXFiles(
[ [
XFile( XFile(
path, filePath,
name: state.document.originalFileName, name: state.document.originalFileName,
mimeType: "application/pdf", mimeType: "application/pdf",
lastModified: state.document.modified, lastModified: state.document.modified,
@@ -93,12 +163,21 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
); );
} }
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 @override
Future<void> close() { Future<void> close() async {
for (final element in _subscriptions) { for (final element in _subscriptions) {
element.cancel(); await element.cancel();
} }
_notifier.unsubscribe(this); _notifier.unsubscribe(this);
return super.close(); await super.close();
} }
} }

View File

@@ -2,12 +2,14 @@ part of 'document_details_cubit.dart';
class DocumentDetailsState with EquatableMixin { class DocumentDetailsState with EquatableMixin {
final DocumentModel document; final DocumentModel document;
final DocumentMetaData? metaData;
final bool isFullContentLoaded; final bool isFullContentLoaded;
final String? fullContent; final String? fullContent;
final FieldSuggestions suggestions; final FieldSuggestions suggestions;
const DocumentDetailsState({ const DocumentDetailsState({
required this.document, required this.document,
this.metaData,
this.suggestions = const FieldSuggestions(), this.suggestions = const FieldSuggestions(),
this.isFullContentLoaded = false, this.isFullContentLoaded = false,
this.fullContent, this.fullContent,
@@ -19,6 +21,7 @@ class DocumentDetailsState with EquatableMixin {
suggestions, suggestions,
isFullContentLoaded, isFullContentLoaded,
fullContent, fullContent,
metaData,
]; ];
DocumentDetailsState copyWith({ DocumentDetailsState copyWith({
@@ -26,12 +29,14 @@ class DocumentDetailsState with EquatableMixin {
FieldSuggestions? suggestions, FieldSuggestions? suggestions,
bool? isFullContentLoaded, bool? isFullContentLoaded,
String? fullContent, String? fullContent,
DocumentMetaData? metaData,
}) { }) {
return DocumentDetailsState( return DocumentDetailsState(
document: document ?? this.document, document: document ?? this.document,
suggestions: suggestions ?? this.suggestions, suggestions: suggestions ?? this.suggestions,
isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded, isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded,
fullContent: fullContent ?? this.fullContent, fullContent: fullContent ?? this.fullContent,
metaData: metaData ?? this.metaData,
); );
} }
} }

View File

@@ -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,
);
}
}

View File

@@ -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_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_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_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/cubit/document_edit_cubit.dart';
import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart'; import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart';
@@ -40,7 +41,7 @@ class DocumentDetailsPage extends StatefulWidget {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> { class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
late Future<DocumentMetaData> _metaData; late Future<DocumentMetaData> _metaData;
static const double _itemPadding = 24; static const double _itemSpacing = 24;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -71,6 +72,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
setState(() {}); setState(() {});
}, },
child: Scaffold( child: Scaffold(
extendBodyBehindAppBar: false,
floatingActionButtonLocation: floatingActionButtonLocation:
FloatingActionButtonLocation.endDocked, FloatingActionButtonLocation.endDocked,
floatingActionButton: widget.allowEdit ? _buildEditButton() : null, floatingActionButton: widget.allowEdit ? _buildEditButton() : null,
@@ -78,15 +80,47 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
body: NestedScrollView( body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [ headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar( SliverAppBar(
title: Text(context
.watch<DocumentDetailsCubit>()
.state
.document
.title),
leading: const BackButton(), leading: const BackButton(),
floating: true,
pinned: true, pinned: true,
expandedHeight: 200.0, forceElevated: innerBoxIsScrolled,
flexibleSpace: collapsedHeight: kToolbarHeight,
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( expandedHeight: 250.0,
builder: (context, state) => DocumentPreview( flexibleSpace: FlexibleSpaceBar(
document: state.document, background: Stack(
fit: BoxFit.cover, alignment: Alignment.topCenter,
children: [
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
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( bottom: ColoredTabBar(
@@ -150,7 +184,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
children: [ children: [
DocumentOverviewWidget( DocumentOverviewWidget(
document: state.document, document: state.document,
itemSpacing: _itemPadding, itemSpacing: _itemSpacing,
queryString: widget.titleAndContentQueryString, queryString: widget.titleAndContentQueryString,
), ),
DocumentContentWidget( DocumentContentWidget(
@@ -161,8 +195,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
DocumentMetaDataWidget( DocumentMetaDataWidget(
document: state.document, document: state.document,
itemSpacing: _itemPadding, itemSpacing: _itemSpacing,
metaData: _metaData,
), ),
const SimilarDocumentsView(), const SimilarDocumentsView(),
], ],
@@ -230,13 +263,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
? () => _onDelete(state.document) ? () => _onDelete(state.document)
: null, : null,
).paddedSymmetrically(horizontal: 4), ).paddedSymmetrically(horizontal: 4),
Tooltip( DocumentDownloadButton(
message: S.of(context)!.downloadDocumentTooltip, document: state.document,
child: DocumentDownloadButton( enabled: isConnected,
document: state.document, metaData: _metaData,
enabled: isConnected,
metaData: _metaData,
),
), ),
IconButton( IconButton(
tooltip: S.of(context)!.previewTooltip, tooltip: S.of(context)!.previewTooltip,
@@ -249,14 +279,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
onPressed: isConnected ? _onOpenFileInSystemViewer : null, onPressed: isConnected ? _onOpenFileInSystemViewer : null,
).paddedOnly(right: 4.0), ).paddedOnly(right: 4.0),
IconButton( DocumentShareButton(document: state.document),
tooltip: S.of(context)!.shareTooltip,
icon: const Icon(Icons.share),
onPressed: isConnected
? () =>
context.read<DocumentDetailsCubit>().shareDocument()
: null,
),
], ],
); );
}, },

View File

@@ -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<ArchiveSerialNumberField> createState() =>
_ArchiveSerialNumberFieldState();
}
class _ArchiveSerialNumberFieldState extends State<ArchiveSerialNumberField> {
late final TextEditingController _asnEditingController;
late bool _showClearButton;
bool _canUpdate = false;
Map<String, dynamic> _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<DocumentDetailsCubit, DocumentDetailsState>(
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<void> _onSubmitted() async {
final value = _asnEditingController.text;
final asn = int.tryParse(value);
await context
.read<DocumentDetailsCubit>()
.assignAsn(widget.document, asn: asn)
.then((value) => _onAsnUpdated())
.onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage(context, error, stackTrace),
)
.onError<PaperlessValidationErrors>(
(error, stackTrace) => setState(() => _errors = error),
);
FocusScope.of(context).unfocus();
}
Future<void> _onAutoAssign() async {
await context
.read<DocumentDetailsCubit>()
.assignAsn(
widget.document,
autoAssign: true,
)
.then((value) => _onAsnUpdated())
.onError<PaperlessServerException>(
(error, stackTrace) => showErrorMessage(context, error, stackTrace),
);
}
void _onAsnUpdated() {
setState(() => _errors = {});
FocusScope.of(context).unfocus();
showSnackBar(context, S.of(context)!.archiveSerialNumberUpdated);
}
}

View File

@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -34,6 +37,7 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return IconButton(
tooltip: S.of(context)!.downloadDocumentTooltip,
icon: _isDownloadPending icon: _isDownloadPending
? const SizedBox( ? const SizedBox(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
@@ -48,25 +52,10 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
} }
Future<void> _onDownload(DocumentModel document) async { Future<void> _onDownload(DocumentModel document) async {
final api = context.read<PaperlessDocumentsApi>();
final meta = await widget.metaData;
try { try {
final downloadOriginal = await showDialog<bool>( final downloadOriginal = await showDialog<bool>(
context: context, context: context,
builder: (context) => RadioSettingsDialog( builder: (context) => const SelectFileTypeDialog(),
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,
),
); );
if (downloadOriginal == null) { if (downloadOriginal == null) {
// Download was cancelled // Download was cancelled
@@ -79,20 +68,14 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
} }
} }
setState(() => _isDownloadPending = true); setState(() => _isDownloadPending = true);
final bytes = await api.download( await context.read<DocumentDetailsCubit>().downloadDocument(
document, downloadOriginal: downloadOriginal,
original: downloadOriginal, locale: context
); .read<ApplicationSettingsCubit>()
final Directory dir = await FileService.downloadsDirectory; .state
final fileExtension = .preferredLocaleSubtag,
downloadOriginal ? meta.mediaFilename.split(".").last : 'pdf'; );
String filePath = "${dir.path}/${meta.mediaFilename}".split(".").first; // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
filePath += ".$fileExtension";
final createdFile = File(filePath);
createdFile.createSync(recursive: true);
createdFile.writeAsBytesSync(bytes);
debugPrint("Downloaded file to $filePath");
showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} catch (error) { } catch (error) {

View File

@@ -2,98 +2,82 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.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/features/document_details/view/widgets/details_item.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentMetaDataWidget extends StatelessWidget { class DocumentMetaDataWidget extends StatefulWidget {
final Future<DocumentMetaData> metaData;
final DocumentModel document; final DocumentModel document;
final double itemSpacing; final double itemSpacing;
const DocumentMetaDataWidget({ const DocumentMetaDataWidget({
super.key, super.key,
required this.metaData,
required this.document, required this.document,
required this.itemSpacing, required this.itemSpacing,
}); });
@override @override
Widget build(BuildContext context) { State<DocumentMetaDataWidget> createState() => _DocumentMetaDataWidgetState();
return BlocBuilder<ConnectivityCubit, ConnectivityState>( }
builder: (context, connectivity) {
return FutureBuilder<DocumentMetaData>(
future: metaData,
builder: (context, snapshot) {
if (!connectivity.isConnected && !snapshot.hasData) {
return OfflineWidget();
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final meta = snapshot.data!; class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
return ListView( @override
padding: const EdgeInsets.symmetric( Widget build(BuildContext context) {
vertical: 16, return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
horizontal: 16, 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: [ children: [
DetailsItem( ArchiveSerialNumberField(
label: S.of(context)!.archiveSerialNumber, document: widget.document,
content: document.archiveSerialNumber != null ).paddedOnly(bottom: widget.itemSpacing),
? 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),
DetailsItem.text( DetailsItem.text(
meta.mediaFilename, DateFormat().format(widget.document.modified),
context: context,
label: S.of(context)!.modifiedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
DateFormat().format(widget.document.added),
context: context,
label: S.of(context)!.addedAt,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.mediaFilename,
context: context, context: context,
label: S.of(context)!.mediaFilename, label: S.of(context)!.mediaFilename,
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text( DetailsItem.text(
meta.originalChecksum, state.metaData!.originalChecksum,
context: context, context: context,
label: S.of(context)!.originalMD5Checksum, label: S.of(context)!.originalMD5Checksum,
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(formatBytes(meta.originalSize, 2),
label: S.of(context)!.originalFileSize,
context: context)
.paddedOnly(bottom: itemSpacing),
DetailsItem.text( DetailsItem.text(
meta.originalMimeType, formatBytes(state.metaData!.originalSize, 2),
label: S.of(context)!.originalMIMEType,
context: context, context: context,
).paddedOnly(bottom: itemSpacing), label: S.of(context)!.originalFileSize,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text(
state.metaData!.originalMimeType,
context: context,
label: S.of(context)!.originalMIMEType,
).paddedOnly(bottom: widget.itemSpacing),
], ],
); ),
}, ),
); );
}, },
); );
} }
Future<void> _assignAsn(BuildContext context) async {
try {
await context.read<DocumentDetailsCubit>().assignAsn(document);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
} }

View File

@@ -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<DocumentShareButton> createState() => _DocumentShareButtonState();
}
class _DocumentShareButtonState extends State<DocumentShareButton> {
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<void> _onShare(DocumentModel document) async {
try {
final shareOriginal = await showDialog<bool>(
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<DocumentDetailsCubit>()
.shareDocument(shareOriginal: shareOriginal);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
} catch (error) {
showGenericError(context, error);
} finally {
if (mounted) {
setState(() => _isDownloadPending = false);
}
}
}
}

View File

@@ -38,6 +38,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkDocumentType = "documentType"; static const fkDocumentType = "documentType";
static const fkCreatedDate = "createdAtDate"; static const fkCreatedDate = "createdAtDate";
static const fkStoragePath = 'storagePath'; static const fkStoragePath = 'storagePath';
static const fkContent = 'content';
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false; bool _isSubmitLoading = false;
@@ -55,94 +56,131 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<DocumentEditCubit, DocumentEditState>( return BlocBuilder<DocumentEditCubit, DocumentEditState>(
builder: (context, state) { builder: (context, state) {
return Scaffold( return DefaultTabController(
resizeToAvoidBottomInset: false, length: 2,
floatingActionButton: FloatingActionButton.extended( child: Scaffold(
onPressed: () => _onSubmit(state.document), resizeToAvoidBottomInset: false,
icon: const Icon(Icons.save), floatingActionButton: FloatingActionButton.extended(
label: Text(S.of(context)!.saveChanges), onPressed: () => _onSubmit(state.document),
), icon: const Icon(Icons.save),
appBar: AppBar( label: Text(S.of(context)!.saveChanges),
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,
), ),
child: FormBuilder( appBar: AppBar(
key: _formKey, title: Text(S.of(context)!.editDocument),
child: ListView( bottom: TabBar(
children: [ tabs: [
_buildTitleFormField(state.document.title).padded(), Tab(
_buildCreatedAtFormField(state.document.created).padded(), text: S.of(context)!.overview,
_buildCorrespondentFormField( ),
state.document.correspondent, Tab(
state.correspondents, text: S.of(context)!.content,
).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<int>(
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
], ],
), ),
), ),
)); 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<int>(
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<DocumentEditPage> {
if (_formKey.currentState?.saveAndValidate() ?? false) { if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value; final values = _formKey.currentState!.value;
var mergedDocument = document.copyWith( var mergedDocument = document.copyWith(
title: values[fkTitle], title: values[fkTitle],
created: values[fkCreatedDate], created: values[fkCreatedDate],
documentType: () => (values[fkDocumentType] as IdQueryParameter).id, documentType: () => (values[fkDocumentType] as IdQueryParameter).id,
correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id, correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id,
storagePath: () => (values[fkStoragePath] as IdQueryParameter).id, storagePath: () => (values[fkStoragePath] as IdQueryParameter).id,
tags: (values[fkTags] as IdsTagsQuery).includedIds, tags: (values[fkTags] as IdsTagsQuery).includedIds,
); content: values[fkContent]);
setState(() { setState(() {
_isSubmitLoading = true; _isSubmitLoading = true;
}); });

View File

@@ -217,7 +217,7 @@ class InboxCubit extends HydratedCubit<InboxState>
if (document.archiveSerialNumber == null) { if (document.archiveSerialNumber == null) {
final int asn = await _documentsApi.findNextAsn(); final int asn = await _documentsApi.findNextAsn();
final updatedDocument = await _documentsApi final updatedDocument = await _documentsApi
.update(document.copyWith(archiveSerialNumber: asn)); .update(document.copyWith(archiveSerialNumber: () => asn));
replace(updatedDocument); replace(updatedDocument);
} }

View File

@@ -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<NotificationTapResponsePayload, Map<String, dynamic>> {
const NotificationTapResponsePayloadConverter();
@override
NotificationTapResponsePayload fromJson(Map<String, dynamic> json) {
final type = NotificationResponseOpenAction.values.byName(json['type']);
switch (type) {
case NotificationResponseOpenAction.openDownloadedDocumentPath:
return OpenDownloadedDocumentPayload.fromJson(
json,
);
}
}
@override
Map<String, dynamic> toJson(NotificationTapResponsePayload object) {
return object.toJson();
}
}

View File

@@ -0,0 +1,11 @@
import 'package:json_annotation/json_annotation.dart';
enum NotificationResponseButtonAction {
openCreatedDocument,
acknowledgeCreatedDocument;
}
@JsonEnum()
enum NotificationResponseOpenAction {
openDownloadedDocumentPath;
}

View File

@@ -1,5 +1,6 @@
enum NotificationChannel { enum NotificationChannel {
task("task_channel", "Paperless Tasks"); task("task_channel", "Paperless tasks"),
documentDownload("document_download_channel", "Document downloads");
final String id; final String id;
final String name; final String name;

View File

@@ -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<String, dynamic> json) =>
_$CreateDocumentSuccessPayloadFromJson(json);
Map<String, dynamic> toJson() => _$CreateDocumentSuccessPayloadToJson(this);
}

View File

@@ -0,0 +1,8 @@
import 'package:paperless_mobile/features/notifications/models/notification_actions.dart';
abstract class NotificationTapResponsePayload {
final NotificationResponseOpenAction type;
Map<String, dynamic> toJson();
NotificationTapResponsePayload({required this.type});
}

View File

@@ -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<String, dynamic> json) =>
_$OpenDownloadedDocumentPayloadFromJson(json);
@override
Map<String, dynamic> toJson() => _$OpenDownloadedDocumentPayloadToJson(this);
}

View File

@@ -2,11 +2,16 @@ import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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_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/converters/notification_tap_response_payload.dart';
import 'package:paperless_mobile/features/notifications/services/notification_actions.dart'; import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart';
import 'package:paperless_mobile/features/notifications/services/notification_channels.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 { class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin = final FlutterLocalNotificationsPlugin _plugin =
@@ -16,7 +21,7 @@ class LocalNotificationService {
Future<void> initialize() async { Future<void> initialize() async {
const AndroidInitializationSettings initializationSettingsAndroid = const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('ic_stat_paperless_logo_green'); AndroidInitializationSettings('paperless_logo_green');
final DarwinInitializationSettings initializationSettingsDarwin = final DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings( DarwinInitializationSettings(
requestSoundPermission: false, requestSoundPermission: false,
@@ -32,6 +37,8 @@ class LocalNotificationService {
await _plugin.initialize( await _plugin.initialize(
initializationSettings, initializationSettings,
onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
onDidReceiveBackgroundNotificationResponse:
onDidReceiveBackgroundNotificationResponse,
); );
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
@@ -39,6 +46,51 @@ class LocalNotificationService {
?.requestPermission(); ?.requestPermission();
} }
Future<void> 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 //TODO: INTL
Future<void> notifyTaskChanged(Task task) { Future<void> notifyTaskChanged(Task task) {
log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}");
@@ -49,20 +101,17 @@ class LocalNotificationService {
late int timestampMillis; late int timestampMillis;
bool showProgress = bool showProgress =
status == TaskStatus.started || status == TaskStatus.pending; status == TaskStatus.started || status == TaskStatus.pending;
int progress = 0;
dynamic payload; dynamic payload;
switch (status) { switch (status) {
case TaskStatus.started: case TaskStatus.started:
title = "Document received"; title = "Document received";
body = task.taskFileName; body = task.taskFileName;
timestampMillis = task.dateCreated.millisecondsSinceEpoch; timestampMillis = task.dateCreated.millisecondsSinceEpoch;
progress = 10;
break; break;
case TaskStatus.pending: case TaskStatus.pending:
title = "Processing document..."; title = "Processing document...";
body = task.taskFileName; body = task.taskFileName;
timestampMillis = task.dateCreated.millisecondsSinceEpoch; timestampMillis = task.dateCreated.millisecondsSinceEpoch;
progress = 70;
break; break;
case TaskStatus.failure: case TaskStatus.failure:
title = "Failed to process document"; title = "Failed to process document";
@@ -73,7 +122,7 @@ class LocalNotificationService {
title = "Document successfully created"; title = "Document successfully created";
body = task.taskFileName; body = task.taskFileName;
timestampMillis = task.dateDone!.millisecondsSinceEpoch; timestampMillis = task.dateDone!.millisecondsSinceEpoch;
payload = CreateDocumentSuccessNotificationResponsePayload( payload = CreateDocumentSuccessPayload(
task.relatedDocument!, task.relatedDocument!,
); );
break; break;
@@ -93,7 +142,7 @@ class LocalNotificationService {
showProgress: showProgress, showProgress: showProgress,
maxProgress: 100, maxProgress: 100,
when: timestampMillis, when: timestampMillis,
progress: progress, indeterminate: true,
actions: status == TaskStatus.success actions: status == TaskStatus.success
? [ ? [
//TODO: Implement once moved to new routing //TODO: Implement once moved to new routing
@@ -109,6 +158,7 @@ class LocalNotificationService {
] ]
: [], : [],
), ),
//TODO: Add darwin support
), ),
payload: jsonEncode(payload), payload: jsonEncode(payload),
); );
@@ -119,38 +169,68 @@ class LocalNotificationService {
String? title, String? title,
String? body, String? body,
String? payload, 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);
}
// Non-actionable notification pressed, ignoring...
} }
void _handleResponseAction( void onDidReceiveNotificationResponse(NotificationResponse response) {
NotificationResponseAction action, 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;
}
}
void _handleResponseButtonAction(
NotificationResponseButtonAction action,
NotificationResponse response, NotificationResponse response,
) { ) {
switch (action) { switch (action) {
case NotificationResponseAction.openCreatedDocument: case NotificationResponseButtonAction.openCreatedDocument:
final payload = final payload = CreateDocumentSuccessPayload.fromJson(
CreateDocumentSuccessNotificationResponsePayload.fromJson(
jsonDecode(response.payload!), jsonDecode(response.payload!),
); );
log("Navigate to document ${payload.documentId}"); log("Navigate to document ${payload.documentId}");
break; break;
case NotificationResponseAction.acknowledgeCreatedDocument: case NotificationResponseButtonAction.acknowledgeCreatedDocument:
final payload = final payload = CreateDocumentSuccessPayload.fromJson(
CreateDocumentSuccessNotificationResponsePayload.fromJson(
jsonDecode(response.payload!), jsonDecode(response.payload!),
); );
log("Acknowledge document ${payload.documentId}"); log("Acknowledge document ${payload.documentId}");
break; 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());
} }

View File

@@ -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<String, dynamic> json) =>
_$CreateDocumentSuccessNotificationResponsePayloadFromJson(json);
Map<String, dynamic> toJson() =>
_$CreateDocumentSuccessNotificationResponsePayloadToJson(this);
}

View File

@@ -1,4 +0,0 @@
enum NotificationResponseAction {
openCreatedDocument,
acknowledgeCreatedDocument;
}

View File

@@ -677,5 +677,21 @@
"dynamicColorScheme": "Dynamicky", "dynamicColorScheme": "Dynamicky",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Klasicky", "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"
}
} }

View File

@@ -677,5 +677,21 @@
"dynamicColorScheme": "Dynamisch", "dynamicColorScheme": "Dynamisch",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Klassisch", "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"
}
} }

View File

@@ -677,5 +677,21 @@
"dynamicColorScheme": "Dynamic", "dynamicColorScheme": "Dynamic",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Classic", "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"
}
} }

View File

@@ -516,15 +516,15 @@
"@password": {}, "@password": {},
"passwordMustNotBeEmpty": "Le mot de passe ne doit pas être vide.", "passwordMustNotBeEmpty": "Le mot de passe ne doit pas être vide.",
"@passwordMustNotBeEmpty": {}, "@passwordMustNotBeEmpty": {},
"connectionTimedOut": "La connection a expiré.", "connectionTimedOut": "La connexion a expiré.",
"@connectionTimedOut": {}, "@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": {}, "@loginPageReachabilityMissingClientCertificateText": {},
"couldNotEstablishConnectionToTheServer": "Impossible d'établir la connection jusqu'au serveur.", "couldNotEstablishConnectionToTheServer": "Impossible d'établir la connexion jusqu'au serveur.",
"@couldNotEstablishConnectionToTheServer": {}, "@couldNotEstablishConnectionToTheServer": {},
"connectionSuccessfulylEstablished": "Connection établie avec succès.", "connectionSuccessfulylEstablished": "Connexion établie avec succès.",
"@connectionSuccessfulylEstablished": {}, "@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": {}, "@hostCouldNotBeResolved": {},
"serverAddress": "Adresse du Serveur", "serverAddress": "Adresse du Serveur",
"@serverAddress": {}, "@serverAddress": {},
@@ -532,7 +532,7 @@
"@invalidAddress": {}, "@invalidAddress": {},
"serverAddressMustIncludeAScheme": "L'adresse du serveur doit respecter le schéma.", "serverAddressMustIncludeAScheme": "L'adresse du serveur doit respecter le schéma.",
"@serverAddressMustIncludeAScheme": {}, "@serverAddressMustIncludeAScheme": {},
"serverAddressMustNotBeEmpty": "L'addresse du serveur ne doit pas être vide.", "serverAddressMustNotBeEmpty": "L'adresse du serveur ne doit pas être vide.",
"@serverAddressMustNotBeEmpty": {}, "@serverAddressMustNotBeEmpty": {},
"signIn": "Se connecter", "signIn": "Se connecter",
"@signIn": {}, "@signIn": {},
@@ -574,7 +574,7 @@
"@documentMatchesThisRegularExpression": {}, "@documentMatchesThisRegularExpression": {},
"regularExpression": "Expression Régulière", "regularExpression": "Expression Régulière",
"@regularExpression": {}, "@regularExpression": {},
"anInternetConnectionCouldNotBeEstablished": "Impossible d'établir une connection internet.", "anInternetConnectionCouldNotBeEstablished": "Impossible d'établir une connexion internet.",
"@anInternetConnectionCouldNotBeEstablished": {}, "@anInternetConnectionCouldNotBeEstablished": {},
"done": "Fait", "done": "Fait",
"@done": {}, "@done": {},
@@ -622,7 +622,7 @@
"@languageAndVisualAppearance": {}, "@languageAndVisualAppearance": {},
"applicationSettings": "Application", "applicationSettings": "Application",
"@applicationSettings": {}, "@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": {}, "@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": "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": {}, "@colorSchemeNotSupportedWarning": {},
@@ -673,9 +673,25 @@
"list": "Liste", "list": "Liste",
"@list": {}, "@list": {},
"remove": "Retirer", "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": "Dynamique",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Classique", "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"
}
} }

View File

@@ -677,5 +677,21 @@
"dynamicColorScheme": "Dynamic", "dynamicColorScheme": "Dynamic",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Classic", "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"
}
} }

View File

@@ -677,5 +677,21 @@
"dynamicColorScheme": "Dynamic", "dynamicColorScheme": "Dynamic",
"@dynamicColorScheme": {}, "@dynamicColorScheme": {},
"classicColorScheme": "Classic", "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"
}
} }

View File

@@ -16,6 +16,7 @@ class DocumentDetailsRoute extends StatelessWidget {
return BlocProvider( return BlocProvider(
create: (context) => DocumentDetailsCubit( create: (context) => DocumentDetailsCubit(
context.read(),
context.read(), context.read(),
context.read(), context.read(),
initialDocument: args.document, initialDocument: args.document,

View File

@@ -76,7 +76,7 @@ class DocumentModel extends Equatable {
DateTime? created, DateTime? created,
DateTime? modified, DateTime? modified,
DateTime? added, DateTime? added,
int? archiveSerialNumber, int? Function()? archiveSerialNumber,
String? originalFileName, String? originalFileName,
String? archivedFileName, String? archivedFileName,
}) { }) {
@@ -84,17 +84,18 @@ class DocumentModel extends Equatable {
id: id, id: id,
title: title ?? this.title, title: title ?? this.title,
content: content ?? this.content, content: content ?? this.content,
documentType: documentType: documentType != null ? documentType() : this.documentType,
documentType != null ? documentType.call() : this.documentType,
correspondent: correspondent:
correspondent != null ? correspondent.call() : this.correspondent, correspondent != null ? correspondent() : this.correspondent,
storagePath: storagePath != null ? storagePath.call() : this.storagePath, storagePath: storagePath != null ? storagePath() : this.storagePath,
tags: tags ?? this.tags, tags: tags ?? this.tags,
created: created ?? this.created, created: created ?? this.created,
modified: modified ?? this.modified, modified: modified ?? this.modified,
added: added ?? this.added, added: added ?? this.added,
originalFileName: originalFileName ?? this.originalFileName, originalFileName: originalFileName ?? this.originalFileName,
archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber, archiveSerialNumber: archiveSerialNumber != null
? archiveSerialNumber()
: this.archiveSerialNumber,
archivedFileName: archivedFileName ?? this.archivedFileName, archivedFileName: archivedFileName ?? this.archivedFileName,
); );
} }

View File

@@ -24,6 +24,12 @@ abstract class PaperlessDocumentsApi {
Future<Uint8List> getPreview(int docId); Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId); String getThumbnailUrl(int docId);
Future<Uint8List> download(DocumentModel document, {bool original}); Future<Uint8List> download(DocumentModel document, {bool original});
Future<void> downloadToFile(
DocumentModel document,
String localFilePath, {
bool original = false,
void Function(double)? onProgressChanged,
});
Future<FieldSuggestions> findSuggestions(DocumentModel document); Future<FieldSuggestions> findSuggestions(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]); Future<List<String>> autocomplete(String query, [int limit = 10]);

View File

@@ -199,7 +199,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
try { try {
final response = await client.get( final response = await client.get(
"/api/documents/${document.id}/download/", "/api/documents/${document.id}/download/",
queryParameters: original ? {'original': true} : {}, queryParameters: {'original': original},
options: Options(responseType: ResponseType.bytes), options: Options(responseType: ResponseType.bytes),
); );
return response.data; return response.data;
@@ -208,6 +208,27 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
} }
} }
@override
Future<void> 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 @override
Future<DocumentMetaData> getMetaData(DocumentModel document) async { Future<DocumentMetaData> getMetaData(DocumentModel document) async {
try { try {

View File

@@ -824,6 +824,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.0" 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: integration_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View File

@@ -90,6 +90,7 @@ dependencies:
flutter_displaymode: ^0.5.0 flutter_displaymode: ^0.5.0
dynamic_color: ^1.5.4 dynamic_color: ^1.5.4
flutter_html: ^3.0.0-alpha.6 flutter_html: ^3.0.0-alpha.6
in_app_review: ^2.0.6
dev_dependencies: dev_dependencies:
integration_test: integration_test: