diff --git a/lib/pages/transaction.dart b/lib/pages/transaction.dart index 4bb60fdf..10d6a71c 100644 --- a/lib/pages/transaction.dart +++ b/lib/pages/transaction.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -805,6 +807,7 @@ class _TransactionPageState extends State ScaffoldMessenger.of(context); final FireflyIii api = context.read().api; final NavigatorState nav = Navigator.of(context); + final AuthUser? user = context.read().user; // Sanity checks String? error; @@ -815,6 +818,9 @@ class _TransactionPageState extends State if (_titleTextController.text.isEmpty) { error = S.of(context).transactionErrorTitle; } + if (user == null) { + error = S.of(context).errorAPIUnavailable; + } if (error != null) { msg.showSnackBar(SnackBar( content: Text(error), @@ -997,6 +1003,72 @@ class _TransactionPageState extends State return; } + // Upload attachments if required + if ((_attachments?.isNotEmpty ?? false) && + _transactionJournalIDs + .firstWhereOrNull((String? e) => e != null) == + null) { + log.fine(() => + "uploading ${_attachments!.length} attachments"); + TransactionSplit? tx = resp + .body?.data.attributes.transactions + .firstWhereOrNull((TransactionSplit e) => + e.transactionJournalId != null); + if (tx != null) { + String txId = tx.transactionJournalId!; + log.finest(() => "uploading to txId $txId"); + for (AttachmentRead attachment in _attachments!) { + log.finest(() => + "uploading attachment ${attachment.id}: ${attachment.attributes.filename}"); + final Response respAttachment = + await api.v1AttachmentsPost( + body: AttachmentStore( + filename: attachment.attributes.filename, + attachableType: AttachableType.transactionjournal, + attachableId: txId, + ), + ); + if (!respAttachment.isSuccessful || + respAttachment.body == null) { + log.warning(() => "error uploading attachment"); + continue; + } + final AttachmentRead newAttachment = + respAttachment.body!.data; + log.finest( + () => "attachment id is ${newAttachment.id}"); + final HttpClientRequest request = + await HttpClient().postUrl( + Uri.parse(newAttachment.attributes.uploadUrl!), + ); + user!.headers().forEach( + (String key, String value) => + request.headers.add(key, value), + ); + request.headers.set(HttpHeaders.contentTypeHeader, + "application/octet-stream"); + final Stream> listenStream = + File(attachment.attributes.uploadUrl!) + .openRead() + .transform( + StreamTransformer, + List>.fromHandlers( + handleData: (List data, + EventSink> sink) { + sink.add(data); + }, + handleDone: (EventSink> sink) { + sink.close(); + }, + ), + ); + await request.addStream(listenStream); + await request.close(); + log.fine(() => "done uploading attachment"); + } + } + } + if (nav.canPop()) { nav.pop(true); } else { @@ -1073,22 +1145,14 @@ class _TransactionPageState extends State icon: Icons.attach_file, tooltip: S.of(context).transactionAttachments, onPressed: () async { - String? txId = _transactionJournalIDs - .firstWhereOrNull((String? element) => element != null); - if (txId == null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(S.of(context).transactionErrorSaveFirst), - behavior: SnackBarBehavior.floating, - )); - return; - } List dialogAttachments = _attachments ?? []; await showDialog>( context: context, builder: (BuildContext context) => AttachmentDialog( attachments: dialogAttachments, - transactionId: txId, + transactionId: _transactionJournalIDs + .firstWhereOrNull((String? element) => element != null), ), ); setState(() { diff --git a/lib/pages/transaction/attachments.dart b/lib/pages/transaction/attachments.dart index f1b69c7f..ea1ca98b 100644 --- a/lib/pages/transaction/attachments.dart +++ b/lib/pages/transaction/attachments.dart @@ -26,7 +26,7 @@ class AttachmentDialog extends StatefulWidget { }); final List attachments; - final String transactionId; + final String? transactionId; @override State createState() => _AttachmentDialogState(); @@ -38,6 +38,273 @@ class _AttachmentDialogState extends State final Map _dlProgress = {}; + void downloadAttachment( + BuildContext context, + AttachmentRead attachment, + int i, + ) async { + final ScaffoldMessengerState msg = ScaffoldMessenger.of(context); + final AuthUser? user = context.read().user; + final S l10n = S.of(context); + late int total; + int received = 0; + final List fileData = []; + + if (user == null) { + throw Exception(l10n.errorAPIUnavailable); + } + + final Directory tmpPath = await getTemporaryDirectory(); + final String filePath = "${tmpPath.path}/${attachment.attributes.filename}"; + + final HttpClientRequest request = await HttpClient().getUrl( + Uri.parse(attachment.attributes.downloadUrl!), + ); + user.headers().forEach( + (String key, String value) => request.headers.add(key, value), + ); + final HttpClientResponse resp = await request.close(); + if (resp.statusCode != 200) { + log.warning("got invalid status code ${resp.statusCode}"); + msg.showSnackBar(SnackBar( + content: Text(l10n.transactionDialogAttachmentsErrorDownload), + behavior: SnackBarBehavior.floating, + )); + return; + } + total = resp.headers.contentLength; + if (total == 0) { + total = attachment.attributes.size ?? 0; + } + resp.listen( + (List value) { + setState(() { + fileData.addAll(value); + received += value.length; + _dlProgress[i] = received / total; + log.finest( + () => + "received ${value.length} bytes (total $received of $total), ${received / total * 100}%", + ); + }); + }, + cancelOnError: true, + onDone: () async { + setState(() { + _dlProgress.remove(i); + }); + log.finest(() => "writing ${fileData.length} bytes to $filePath"); + await File(filePath).writeAsBytes(fileData, flush: true); + final OpenResult file = await OpenFilex.open(filePath); + if (file.type != ResultType.done) { + log.severe("error opening file", file.message); + msg.showSnackBar(SnackBar( + content: + Text(l10n.transactionDialogAttachmentsErrorOpen(file.message)), + behavior: SnackBarBehavior.floating, + )); + } + }, + onError: (Error e) { + log.severe("download error", e); + setState(() { + _dlProgress.remove(i); + }); + msg.showSnackBar(SnackBar( + content: Text(l10n.transactionDialogAttachmentsErrorDownload), + behavior: SnackBarBehavior.floating, + )); + }, + ); + } + + void deleteAttachment( + BuildContext context, + AttachmentRead attachment, + int i, + ) async { + final FireflyIii api = context.read().api; + bool? ok = await showDialog( + context: context, + builder: (BuildContext context) => + const AttachmentDeletionConfirmDialog(), + ); + if (ok == null || !ok) { + return; + } + + await api.v1AttachmentsIdDelete(id: attachment.id); + setState(() { + widget.attachments.removeAt(i); + }); + } + + void uploadAttachment(BuildContext context) async { + final ScaffoldMessengerState msg = ScaffoldMessenger.of(context); + final FireflyIii api = context.read().api; + final AuthUser? user = context.read().user; + final S l10n = S.of(context); + + if (user == null) { + throw Exception(l10n.errorAPIUnavailable); + } + + FilePickerResult? file = await FilePicker.platform.pickFiles(); + if (file == null || file.files.first.path == null) { + return; + } + final Response respAttachment = + await api.v1AttachmentsPost( + body: AttachmentStore( + filename: file.files.first.name, + attachableType: AttachableType.transactionjournal, + attachableId: widget.transactionId!, + ), + ); + if (!respAttachment.isSuccessful || respAttachment.body == null) { + late String error; + try { + ValidationError valError = ValidationError.fromJson( + json.decode(respAttachment.error.toString())); + error = valError.message ?? l10n.errorUnknown; + } catch (_) { + error = l10n.errorUnknown; + } + msg.showSnackBar(SnackBar( + content: Text(l10n.transactionDialogAttachmentsErrorUpload(error)), + behavior: SnackBarBehavior.floating, + )); + return; + } + final AttachmentRead newAttachment = respAttachment.body!.data; + int newAttachmentIndex = + widget.attachments.length; // Will be added later, no -1 needed. + final int total = file.files.first.size; + int sent = 0; + + setState(() { + widget.attachments.add(newAttachment); + _dlProgress[newAttachmentIndex] = -0.0001; + }); + + final HttpClientRequest request = await HttpClient().postUrl( + Uri.parse(newAttachment.attributes.uploadUrl!), + ); + user.headers().forEach( + (String key, String value) => request.headers.add(key, value), + ); + request.headers + .set(HttpHeaders.contentTypeHeader, "application/octet-stream"); + log.fine(() => "AttachmentUpload: Starting Upload $newAttachmentIndex"); + + final Stream> listenStream = + File(file.files.first.path!).openRead().transform( + StreamTransformer, List>.fromHandlers( + handleData: (List data, EventSink> sink) { + setState(() { + sent += data.length; + _dlProgress[newAttachmentIndex] = sent / total * -1; + log.finest( + () => + "sent ${data.length} bytes (total $sent of $total), ${sent / total * 100}%", + ); + }); + sink.add(data); + }, + handleDone: (EventSink> sink) { + sink.close(); + }, + ), + ); + + await request.addStream(listenStream); + final HttpClientResponse resp = await request.close(); + log.fine(() => "AttachmentUpload: Done with Upload $newAttachmentIndex"); + setState(() { + _dlProgress.remove(newAttachmentIndex); + }); + if (resp.statusCode == HttpStatus.ok || + resp.statusCode == HttpStatus.created || + resp.statusCode == HttpStatus.noContent) { + return; + } + late String error; + try { + final String respString = await resp.transform(utf8.decoder).join(); + ValidationError valError = + ValidationError.fromJson(json.decode(respString)); + error = valError.message ?? l10n.errorUnknown; + } catch (_) { + error = l10n.errorUnknown; + } + msg.showSnackBar(SnackBar( + content: Text(l10n.transactionDialogAttachmentsErrorUpload(error)), + behavior: SnackBarBehavior.floating, + )); + await api.v1AttachmentsIdDelete(id: newAttachment.id); + setState(() { + widget.attachments.removeAt(newAttachmentIndex); + }); + } + + void fakeDownloadAttachment( + BuildContext context, + AttachmentRead attachment, + ) async { + final ScaffoldMessengerState msg = ScaffoldMessenger.of(context); + final S l10n = S.of(context); + + final OpenResult file = + await OpenFilex.open(attachment.attributes.uploadUrl); + if (file.type != ResultType.done) { + log.severe("error opening file", file.message); + msg.showSnackBar(SnackBar( + content: Text(l10n.transactionDialogAttachmentsErrorOpen(file.message)), + behavior: SnackBarBehavior.floating, + )); + } + } + + void fakeDeleteAttachment( + BuildContext context, + int i, + ) async { + bool? ok = await showDialog( + context: context, + builder: (BuildContext context) => + const AttachmentDeletionConfirmDialog(), + ); + if (ok == null || !ok) { + return; + } + setState(() { + widget.attachments.removeAt(i); + }); + } + + void fakeUploadAttachment(BuildContext context) async { + FilePickerResult? file = await FilePicker.platform.pickFiles(); + if (file == null || file.files.first.path == null) { + return; + } + + final AttachmentRead newAttachment = AttachmentRead( + type: "attachments", + id: widget.attachments.length.toString(), + attributes: Attachment( + attachableType: AttachableType.transactionjournal, + attachableId: "FAKE", + filename: file.files.first.name, + uploadUrl: file.files.first.path, + size: file.files.first.size, + ), + links: ObjectLink(), + ); + setState(() { + widget.attachments.add(newAttachment); + }); + } + @override Widget build(BuildContext context) { log.finest(() => "build(transactionId: ${widget.transactionId})"); @@ -60,92 +327,11 @@ class _AttachmentDialogState extends State icon: (_dlProgress[i] != null && _dlProgress[i]! < 0) ? Icons.upload : Icons.download, - onPressed: _dlProgress[i] == null - ? () async { - final ScaffoldMessengerState msg = - ScaffoldMessenger.of(context); - final AuthUser? user = context.read().user; - final S l10n = S.of(context); - //late http.StreamedResponse resp; - late int total; - int received = 0; - final List fileData = []; - - if (user == null) { - throw Exception("API not ready."); - } - - final Directory tmpPath = await getTemporaryDirectory(); - final String filePath = - "${tmpPath.path}/${attachment.attributes.filename}"; - total = attachment.attributes.size ?? 0; - - final HttpClientRequest request = await HttpClient().getUrl( - Uri.parse(attachment.attributes.downloadUrl!), - ); - user.headers().forEach( - (String key, String value) => - request.headers.add(key, value), - ); - final HttpClientResponse resp = await request.close(); - if (resp.statusCode != 200) { - log.warning("got invalid status code ${resp.statusCode}"); - msg.showSnackBar(SnackBar( - content: - Text(l10n.transactionDialogAttachmentsErrorDownload), - behavior: SnackBarBehavior.floating, - )); - return; - } - total = resp.headers.contentLength; - if (total == 0) { - total = attachment.attributes.size ?? 0; - } - resp.listen( - (List value) { - setState(() { - fileData.addAll(value); - received += value.length; - _dlProgress[i] = received / total; - log.finest( - () => - "received ${value.length} bytes (total $received of $total), ${received / total * 100}%", - ); - }); - }, - cancelOnError: true, - onDone: () async { - setState(() { - _dlProgress.remove(i); - }); - log.finest(() => - "writing ${fileData.length} bytes to $filePath"); - await File(filePath).writeAsBytes(fileData, flush: true); - final OpenResult file = await OpenFilex.open(filePath); - if (file.type != ResultType.done) { - log.severe("error opening file", file.message); - msg.showSnackBar(SnackBar( - content: Text( - l10n.transactionDialogAttachmentsErrorOpen( - file.message)), - behavior: SnackBarBehavior.floating, - )); - } - }, - onError: (Error e) { - log.severe("download error", e); - setState(() { - _dlProgress.remove(i); - }); - msg.showSnackBar(SnackBar( - content: Text( - l10n.transactionDialogAttachmentsErrorDownload), - behavior: SnackBarBehavior.floating, - )); - }, - ); - } - : null, + onPressed: _dlProgress[i] != null + ? null + : widget.transactionId == null + ? () async => fakeDownloadAttachment(context, attachment) + : () async => downloadAttachment(context, attachment, i), ), title: Text( attachment.attributes.title ?? attachment.attributes.filename, @@ -162,45 +348,9 @@ class _AttachmentDialogState extends State icon: Icons.delete, onPressed: (_dlProgress[i] != null && _dlProgress[i]! < 0) ? null - : () async { - final FireflyIii api = context.read().api; - bool? ok = await showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - icon: const Icon(Icons.delete), - title: Text( - S.of(context).transactionDialogAttachmentsDelete), - clipBehavior: Clip.hardEdge, - actions: [ - TextButton( - child: Text(MaterialLocalizations.of(context) - .cancelButtonLabel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - FilledButton( - child: Text(MaterialLocalizations.of(context) - .deleteButtonTooltip), - onPressed: () { - Navigator.of(context).pop(true); - }, - ), - ], - content: Text(S - .of(context) - .transactionDialogAttachmentsDeleteConfirm), - ), - ); - if (ok == null || !ok) { - return; - } - - await api.v1AttachmentsIdDelete(id: attachment.id); - setState(() { - widget.attachments.removeAt(i); - }); - }, + : widget.transactionId == null + ? () async => fakeDeleteAttachment(context, i) + : () async => deleteAttachment(context, attachment, i), ), )); final DividerThemeData divTheme = DividerTheme.of(context); @@ -232,121 +382,9 @@ class _AttachmentDialogState extends State child: Text(MaterialLocalizations.of(context).closeButtonLabel), ), FilledButton( - onPressed: () async { - final ScaffoldMessengerState msg = ScaffoldMessenger.of(context); - final FireflyIii api = context.read().api; - final AuthUser? user = context.read().user; - final S l10n = S.of(context); - - if (user == null) { - throw Exception("API unavailable"); - } - - FilePickerResult? file = await FilePicker.platform.pickFiles(); - if (file == null || file.files.first.path == null) { - return; - } - final Response respAttachment = - await api.v1AttachmentsPost( - body: AttachmentStore( - filename: file.files.first.name, - attachableType: AttachableType.transactionjournal, - attachableId: widget.transactionId, - ), - ); - if (!respAttachment.isSuccessful || respAttachment.body == null) { - late String error; - try { - ValidationError valError = ValidationError.fromJson( - json.decode(respAttachment.error.toString())); - error = error = valError.message ?? l10n.errorUnknown; - } catch (_) { - error = l10n.errorUnknown; - } - msg.showSnackBar(SnackBar( - content: - Text(l10n.transactionDialogAttachmentsErrorUpload(error)), - behavior: SnackBarBehavior.floating, - )); - return; - } - final AttachmentRead newAttachment = respAttachment.body!.data; - int newAttachmentIndex = widget - .attachments.length; // Will be added later, no -1 needed. - final int total = file.files.first.size; - int sent = 0; - - setState(() { - widget.attachments.add(newAttachment); - _dlProgress[newAttachmentIndex] = -0.0001; - }); - - final HttpClientRequest request = await HttpClient().postUrl( - Uri.parse(newAttachment.attributes.uploadUrl!), - ); - user.headers().forEach( - (String key, String value) => - request.headers.add(key, value), - ); - request.headers.set( - HttpHeaders.contentTypeHeader, "application/octet-stream"); - log.fine(() => - "AttachmentUpload: Starting Upload $newAttachmentIndex"); - - final Stream> listenStream = - File(file.files.first.path!).openRead().transform( - StreamTransformer, List>.fromHandlers( - handleData: - (List data, EventSink> sink) { - setState(() { - sent += data.length; - _dlProgress[newAttachmentIndex] = - sent / total * -1; - log.finest( - () => - "sent ${data.length} bytes (total $sent of $total), ${sent / total * 100}%", - ); - }); - sink.add(data); - }, - handleDone: (EventSink> sink) { - sink.close(); - }, - ), - ); - - await request.addStream(listenStream); - final HttpClientResponse resp = await request.close(); - log.fine(() => - "AttachmentUpload: Done with Upload $newAttachmentIndex"); - setState(() { - _dlProgress.remove(newAttachmentIndex); - }); - if (resp.statusCode == HttpStatus.ok || - resp.statusCode == HttpStatus.created || - resp.statusCode == HttpStatus.noContent) { - return; - } - late String error; - try { - final String respString = - await resp.transform(utf8.decoder).join(); - ValidationError valError = - ValidationError.fromJson(json.decode(respString)); - error = error = valError.message ?? l10n.errorUnknown; - } catch (_) { - error = l10n.errorUnknown; - } - msg.showSnackBar(SnackBar( - content: - Text(l10n.transactionDialogAttachmentsErrorUpload(error)), - behavior: SnackBarBehavior.floating, - )); - await api.v1AttachmentsIdDelete(id: newAttachment.id); - setState(() { - widget.attachments.removeAt(newAttachmentIndex); - }); - }, + onPressed: widget.transactionId == null + ? () async => fakeUploadAttachment(context) + : () async => uploadAttachment(context), child: Text(S.of(context).formButtonUpload), ), const SizedBox(width: 12), @@ -360,3 +398,33 @@ class _AttachmentDialogState extends State ); } } + +class AttachmentDeletionConfirmDialog extends StatelessWidget { + const AttachmentDeletionConfirmDialog({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.delete), + title: Text(S.of(context).transactionDialogAttachmentsDelete), + clipBehavior: Clip.hardEdge, + actions: [ + TextButton( + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + FilledButton( + child: Text(MaterialLocalizations.of(context).deleteButtonTooltip), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + content: Text(S.of(context).transactionDialogAttachmentsDeleteConfirm), + ); + } +}