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

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

View File

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

View File

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

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

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_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) {

View File

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

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

View File

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

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 {
task("task_channel", "Paperless Tasks");
task("task_channel", "Paperless tasks"),
documentDownload("document_download_channel", "Document downloads");
final String id;
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 '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());
}

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;
}