mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-09 14:08:00 -06:00
feat: Improve notifications, add donation button, improved asn form field
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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<DocumentDetailsState> {
|
||||
final PaperlessDocumentsApi _api;
|
||||
final DocumentChangedNotifier _notifier;
|
||||
final LocalNotificationService _notificationService;
|
||||
|
||||
final List<StreamSubscription> _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<void> delete(DocumentModel document) async {
|
||||
@@ -36,6 +42,11 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
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 {
|
||||
final doc = await _api.find(state.document.id);
|
||||
if (doc == null) {
|
||||
@@ -47,11 +58,20 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> assignAsn(DocumentModel document) async {
|
||||
if (document.archiveSerialNumber == null) {
|
||||
final int asn = await _api.findNextAsn();
|
||||
final updatedDocument =
|
||||
await _api.update(document.copyWith(archiveSerialNumber: asn));
|
||||
Future<void> assignAsn(
|
||||
DocumentModel document, {
|
||||
int? asn,
|
||||
bool autoAssign = false,
|
||||
}) async {
|
||||
if (!autoAssign) {
|
||||
final updatedDocument = await _api.update(
|
||||
document.copyWith(archiveSerialNumber: () => asn),
|
||||
);
|
||||
_notifier.notifyUpdated(updatedDocument);
|
||||
} else {
|
||||
final int autoAsn = await _api.findNextAsn();
|
||||
final updatedDocument = await _api
|
||||
.update(document.copyWith(archiveSerialNumber: () => autoAsn));
|
||||
_notifier.notifyUpdated(updatedDocument);
|
||||
}
|
||||
}
|
||||
@@ -59,14 +79,19 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
Future<ResultType> openDocumentInSystemViewer() async {
|
||||
final cacheDir = await FileService.temporaryDirectory;
|
||||
|
||||
final metaData = await _api.getMetaData(state.document);
|
||||
final bytes = await _api.download(state.document);
|
||||
if (state.metaData == null) {
|
||||
await loadMetaData();
|
||||
}
|
||||
|
||||
final file = File('${cacheDir.path}/${metaData.mediaFilename}')
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(bytes);
|
||||
await _api.downloadToFile(
|
||||
state.document,
|
||||
'${cacheDir.path}/${state.metaData!.mediaFilename}',
|
||||
);
|
||||
|
||||
return OpenFilex.open(file.path, type: "application/pdf").then(
|
||||
return OpenFilex.open(
|
||||
'${cacheDir.path}/${state.metaData!.mediaFilename}',
|
||||
type: "application/pdf",
|
||||
).then(
|
||||
(value) => value.type,
|
||||
);
|
||||
}
|
||||
@@ -75,15 +100,60 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
emit(state.copyWith(document: document));
|
||||
}
|
||||
|
||||
Future<void> 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<void> downloadDocument({
|
||||
bool downloadOriginal = false,
|
||||
required String locale,
|
||||
}) async {
|
||||
if (state.metaData == null) {
|
||||
await loadMetaData();
|
||||
}
|
||||
String filePath = _buildDownloadFilePath(
|
||||
downloadOriginal,
|
||||
await FileService.downloadsDirectory,
|
||||
);
|
||||
final desc = FileDescription.fromPath(
|
||||
state.metaData!.mediaFilename
|
||||
.replaceAll("/", " "), // Flatten directory structure
|
||||
);
|
||||
await _notificationService.notifyFileDownload(
|
||||
document: state.document,
|
||||
filename: "${desc.filename}.${desc.extension}",
|
||||
filePath: filePath,
|
||||
finished: false,
|
||||
locale: locale,
|
||||
);
|
||||
await _api.downloadToFile(
|
||||
state.document,
|
||||
filePath,
|
||||
original: downloadOriginal,
|
||||
);
|
||||
await _notificationService.notifyFileDownload(
|
||||
document: state.document,
|
||||
filename: "${desc.filename}.${desc.extension}",
|
||||
filePath: filePath,
|
||||
finished: true,
|
||||
locale: locale,
|
||||
);
|
||||
debugPrint("Downloaded file to $filePath");
|
||||
}
|
||||
|
||||
Future<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(
|
||||
[
|
||||
XFile(
|
||||
path,
|
||||
filePath,
|
||||
name: state.document.originalFileName,
|
||||
mimeType: "application/pdf",
|
||||
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
|
||||
Future<void> close() {
|
||||
Future<void> close() async {
|
||||
for (final element in _subscriptions) {
|
||||
element.cancel();
|
||||
await element.cancel();
|
||||
}
|
||||
_notifier.unsubscribe(this);
|
||||
return super.close();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentDetailsPage> {
|
||||
late Future<DocumentMetaData> _metaData;
|
||||
static const double _itemPadding = 24;
|
||||
static const double _itemSpacing = 24;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -71,6 +72,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
setState(() {});
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: false,
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.endDocked,
|
||||
floatingActionButton: widget.allowEdit ? _buildEditButton() : null,
|
||||
@@ -78,15 +80,47 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverAppBar(
|
||||
title: Text(context
|
||||
.watch<DocumentDetailsCubit>()
|
||||
.state
|
||||
.document
|
||||
.title),
|
||||
leading: const BackButton(),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
expandedHeight: 200.0,
|
||||
flexibleSpace:
|
||||
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
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<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(
|
||||
@@ -150,7 +184,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
children: [
|
||||
DocumentOverviewWidget(
|
||||
document: state.document,
|
||||
itemSpacing: _itemPadding,
|
||||
itemSpacing: _itemSpacing,
|
||||
queryString: widget.titleAndContentQueryString,
|
||||
),
|
||||
DocumentContentWidget(
|
||||
@@ -161,8 +195,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
),
|
||||
DocumentMetaDataWidget(
|
||||
document: state.document,
|
||||
itemSpacing: _itemPadding,
|
||||
metaData: _metaData,
|
||||
itemSpacing: _itemSpacing,
|
||||
),
|
||||
const SimilarDocumentsView(),
|
||||
],
|
||||
@@ -230,13 +263,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
? () => _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<DocumentDetailsPage> {
|
||||
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<DocumentDetailsCubit>().shareDocument()
|
||||
: null,
|
||||
),
|
||||
DocumentShareButton(document: state.document),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/service/file_service.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
|
||||
import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart';
|
||||
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
@@ -34,6 +37,7 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: S.of(context)!.downloadDocumentTooltip,
|
||||
icon: _isDownloadPending
|
||||
? const SizedBox(
|
||||
child: CircularProgressIndicator(),
|
||||
@@ -48,25 +52,10 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
|
||||
}
|
||||
|
||||
Future<void> _onDownload(DocumentModel document) async {
|
||||
final api = context.read<PaperlessDocumentsApi>();
|
||||
final meta = await widget.metaData;
|
||||
try {
|
||||
final downloadOriginal = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => RadioSettingsDialog(
|
||||
titleText: S.of(context)!.chooseFiletype,
|
||||
options: [
|
||||
RadioOption(
|
||||
value: true,
|
||||
label: S.of(context)!.original +
|
||||
" (${meta.originalMimeType.split("/").last})"),
|
||||
RadioOption(
|
||||
value: false,
|
||||
label: S.of(context)!.archivedPdf,
|
||||
),
|
||||
],
|
||||
initialValue: false,
|
||||
),
|
||||
builder: (context) => const SelectFileTypeDialog(),
|
||||
);
|
||||
if (downloadOriginal == null) {
|
||||
// Download was cancelled
|
||||
@@ -79,20 +68,14 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
|
||||
}
|
||||
}
|
||||
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<DocumentDetailsCubit>().downloadDocument(
|
||||
downloadOriginal: downloadOriginal,
|
||||
locale: context
|
||||
.read<ApplicationSettingsCubit>()
|
||||
.state
|
||||
.preferredLocaleSubtag,
|
||||
);
|
||||
// showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<DocumentMetaData> 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<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());
|
||||
}
|
||||
State<DocumentMetaDataWidget> createState() => _DocumentMetaDataWidgetState();
|
||||
}
|
||||
|
||||
final meta = snapshot.data!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
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(
|
||||
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,
|
||||
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,
|
||||
).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
static const fkDocumentType = "documentType";
|
||||
static const fkCreatedDate = "createdAtDate";
|
||||
static const fkStoragePath = 'storagePath';
|
||||
static const fkContent = 'content';
|
||||
|
||||
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
|
||||
bool _isSubmitLoading = false;
|
||||
@@ -55,94 +56,131 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentEditCubit, DocumentEditState>(
|
||||
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<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
|
||||
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<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) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
11
lib/features/notifications/models/notification_actions.dart
Normal file
11
lib/features/notifications/models/notification_actions.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
enum NotificationResponseButtonAction {
|
||||
openCreatedDocument,
|
||||
acknowledgeCreatedDocument;
|
||||
}
|
||||
|
||||
@JsonEnum()
|
||||
enum NotificationResponseOpenAction {
|
||||
openDownloadedDocumentPath;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void> 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<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
|
||||
Future<void> 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,
|
||||
) {}
|
||||
|
||||
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...
|
||||
) {
|
||||
debugPrint("onDidReceiveNotification!");
|
||||
}
|
||||
|
||||
void _handleResponseAction(
|
||||
NotificationResponseAction action,
|
||||
void onDidReceiveNotificationResponse(NotificationResponse 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;
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
enum NotificationResponseAction {
|
||||
openCreatedDocument,
|
||||
acknowledgeCreatedDocument;
|
||||
}
|
||||
Reference in New Issue
Block a user