From 31d7e8dff42d0272d8e02427bce0e99d6694a780 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 1 Mar 2026 19:54:37 -0600 Subject: [PATCH 1/3] auto-label UTXOs from transaction notes closes #411 --- lib/db/isar/main_db.dart | 28 ++++++++++++++ .../confirm_change_now_send.dart | 12 +++--- .../confirm_name_transaction_view.dart | 12 +++--- .../send_view/confirm_transaction_view.dart | 12 +++--- .../confirm_spark_name_transaction_view.dart | 12 +++--- .../transaction_views/edit_note_view.dart | 38 +++++++++++-------- 6 files changed, 78 insertions(+), 36 deletions(-) diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 3b86d74725..b25e74bf8f 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -371,6 +371,34 @@ class MainDB { await isar.transactionNotes.put(transactionNote); }); + /// Auto-labels UTXOs associated with a transaction note. + /// Only labels UTXOs that don't already have a user-set name. + Future autoLabelUTXOsFromNote(TransactionNote note) async { + if (note.value.isEmpty) return; + + final utxos = await isar.utxos + .where() + .walletIdEqualTo(note.walletId) + .filter() + .txidEqualTo(note.txid) + .findAll(); + + if (utxos.isEmpty) return; + + final toUpdate = []; + for (final utxo in utxos) { + if (utxo.name.isEmpty) { + toUpdate.add(utxo.copyWith(name: note.value)); + } + } + + if (toUpdate.isNotEmpty) { + await isar.writeTxn(() async { + await isar.utxos.putAll(toUpdate); + }); + } + } + Future putTransactionNotes(List transactionNotes) => isar.writeTxn(() async { await isar.transactionNotes.putAll(transactionNotes); diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index b98e4e3b0e..56472cd57b 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -122,11 +122,13 @@ class _ConfirmChangeNowSendViewState txid = (results.first as TxData).txid!; // save note - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + final txNote = TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ); + await ref.read(mainDBProvider).putTransactionNote(txNote); + await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); await ref .read(tradeSentFromStackLookupProvider) diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index ff2f0b74a4..52acaccd8d 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -142,11 +142,13 @@ class _ConfirmNameTransactionViewState // save note for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + final txNote = TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ); + await ref.read(mainDBProvider).putTransactionNote(txNote); + await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); } unawaited(wallet.refresh()); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 8f7eab5924..87718f29d0 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -536,11 +536,13 @@ class _ConfirmTransactionViewState // save note for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + final txNote = TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ); + await ref.read(mainDBProvider).putTransactionNote(txNote); + await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); } if (widget.isTokenTx) { diff --git a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart index d1f4e68e3d..2873df4982 100644 --- a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart +++ b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart @@ -121,11 +121,13 @@ class _ConfirmSparkNameTransactionViewState // save note for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + final txNote = TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ); + await ref.read(mainDBProvider).putTransactionNote(txNote); + await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); } final address = txData.sparkNameInfo?.sparkAddress; diff --git a/lib/pages/wallet_view/transaction_views/edit_note_view.dart b/lib/pages/wallet_view/transaction_views/edit_note_view.dart index bcb6202ec3..501607f878 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -187,16 +187,19 @@ class _EditNoteViewState extends ConsumerState { child: PrimaryButton( label: "Save", onPressed: () async { + final note = + _note?.copyWith(value: _noteController.text) ?? + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ); await ref .read(mainDBProvider) - .putTransactionNote( - _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, - ), - ); + .putTransactionNote(note); + await ref + .read(mainDBProvider) + .autoLabelUTXOsFromNote(note); if (mounted) { Navigator.of(context).pop(); @@ -207,16 +210,19 @@ class _EditNoteViewState extends ConsumerState { if (!isDesktop) TextButton( onPressed: () async { + final note = + _note?.copyWith(value: _noteController.text) ?? + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ); await ref .read(mainDBProvider) - .putTransactionNote( - _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, - ), - ); + .putTransactionNote(note); + await ref + .read(mainDBProvider) + .autoLabelUTXOsFromNote(note); if (mounted) { Navigator.of(context).pop(); } From 9045c47d5b4b23b96c93b78ac7360775d1b4152a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 11:55:19 -0500 Subject: [PATCH 2/3] chore: dart format --- .../confirm_name_transaction_view.dart | 461 ++++++++---------- .../transaction_views/edit_note_view.dart | 171 +++---- 2 files changed, 292 insertions(+), 340 deletions(-) diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index 52acaccd8d..baf5a05992 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -227,10 +227,9 @@ class _ConfirmNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -275,81 +274,76 @@ class _ConfirmNameTransactionViewState return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - // if (FocusScope.of(context).hasFocus) { - // FocusScope.of(context).unfocus(); - // await Future.delayed(Duration(milliseconds: 50)); - // } - Navigator.of(context).pop(); - }, - ), - title: Text( - "Confirm transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + // if (FocusScope.of(context).hasFocus) { + // FocusScope.of(context).unfocus(); + // await Future.delayed(Duration(milliseconds: 50)); + // } + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, ), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ - Row( - children: [ - AppBarBackButton( - size: 40, - iconSize: 24, - onPressed: - () => - Navigator.of(context, rootNavigator: true).pop(), - ), - Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - ], + AppBarBackButton( + size: 40, + iconSize: 24, + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(), + ), + Text( + "Confirm transaction", + style: STextStyles.desktopH3(context), ), - Flexible(child: SingleChildScrollView(child: child)), ], ), + Flexible(child: SingleChildScrollView(child: child)), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -489,18 +483,18 @@ class _ConfirmNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: - Theme.of(context).extension()!.background, + borderColor: Theme.of( + context, + ).extension()!.background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -552,24 +546,23 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.name, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), ), Container( height: 1, - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, ), Padding( padding: const EdgeInsets.all(12), @@ -586,14 +579,14 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.value, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -611,14 +604,12 @@ class _ConfirmNameTransactionViewState children: [ SelectableText( "Note (optional)", - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveSearchIconRight, - ), + ), textAlign: TextAlign.left, ), const SizedBox(height: 10), @@ -633,49 +624,48 @@ class _ConfirmNameTransactionViewState enableSuggestions: isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), + height: 1.8, + ), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState( - () => noteController.text = "", - ); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState( + () => + noteController.text = "", + ); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -699,10 +689,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -713,21 +702,17 @@ class _ConfirmNameTransactionViewState String fiatAmount = "N/A"; if (externalCalls) { - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getPrice(coin) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + ?.value; if (price != null && price > Decimal.zero) { fiatAmount = (amountWithoutChange.decimal * price) .toAmount(fractionDigits: 2) .fiatString( - locale: - ref - .read( - localeServiceChangeNotifierProvider, - ) - .locale, + locale: ref + .read(localeServiceChangeNotifierProvider) + .locale, ); } } @@ -772,10 +757,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -799,10 +783,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -829,10 +812,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -842,64 +824,52 @@ class _ConfirmNameTransactionViewState if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 23 : 12), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ) - : const EdgeInsets.all(12), - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) + : const EdgeInsets.all(12), + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.titleBold12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), ), SelectableText( ref .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), textAlign: TextAlign.right, ), ], @@ -908,10 +878,9 @@ class _ConfirmNameTransactionViewState ), SizedBox(height: isDesktop ? 28 : 16), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: PrimaryButton( label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, @@ -921,28 +890,27 @@ class _ConfirmNameTransactionViewState if (isDesktop) { unlocked = await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), - ), - ], + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), ); } else { unlocked = await Navigator.push( @@ -950,18 +918,16 @@ class _ConfirmNameTransactionViewState RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to send transaction", - biometricsAuthenticationTitle: - "Confirm Transaction", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), settings: const RouteSettings( name: "/confirmsendlockscreen", ), @@ -977,10 +943,9 @@ class _ConfirmNameTransactionViewState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: - Util.isDesktop - ? "Invalid passphrase" - : "Invalid PIN", + message: Util.isDesktop + ? "Invalid passphrase" + : "Invalid PIN", context: context, ), ); diff --git a/lib/pages/wallet_view/transaction_views/edit_note_view.dart b/lib/pages/wallet_view/transaction_views/edit_note_view.dart index 501607f878..248e4531f6 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -71,34 +71,33 @@ class _EditNoteViewState extends ConsumerState { condition: !isDesktop, builder: (child) => Background(child: child), child: Scaffold( - backgroundColor: - isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.background, - appBar: - isDesktop - ? null - : AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Edit note", - style: STextStyles.navBarTitle(context), - ), + backgroundColor: isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.background, + appBar: isDesktop + ? null + : AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Edit note", + style: STextStyles.navBarTitle(context), ), + ), body: MobileEditNoteScaffold( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -115,10 +114,9 @@ class _EditNoteViewState extends ConsumerState { ), ), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -127,55 +125,50 @@ class _EditNoteViewState extends ConsumerState { autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: _noteController, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), focusNode: noteFieldFocusNode, - decoration: standardInputDecoration( - "Note", - noteFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Note", + noteFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - _noteController.text.isNotEmpty + suffixIcon: _noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _noteController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _noteController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -189,14 +182,12 @@ class _EditNoteViewState extends ConsumerState { onPressed: () async { final note = _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, - ); - await ref - .read(mainDBProvider) - .putTransactionNote(note); + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ); + await ref.read(mainDBProvider).putTransactionNote(note); await ref .read(mainDBProvider) .autoLabelUTXOsFromNote(note); @@ -212,17 +203,13 @@ class _EditNoteViewState extends ConsumerState { onPressed: () async { final note = _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, - ); - await ref - .read(mainDBProvider) - .putTransactionNote(note); - await ref - .read(mainDBProvider) - .autoLabelUTXOsFromNote(note); + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ); + await ref.read(mainDBProvider).putTransactionNote(note); + await ref.read(mainDBProvider).autoLabelUTXOsFromNote(note); if (mounted) { Navigator.of(context).pop(); } From 5f558a4e2a49070a32f26702757bf927c4aa5ff0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 17:02:10 -0500 Subject: [PATCH 3/3] fix: persist transaction note labels --- lib/db/isar/main_db.dart | 105 ++-- .../cakepay/cakepay_confirm_send_view.dart | 10 +- .../confirm_change_now_send.dart | 11 +- .../confirm_name_transaction_view.dart | 484 ++++++++++-------- .../send_view/confirm_transaction_view.dart | 20 +- .../shopinbit_confirm_send_view.dart | 15 +- .../confirm_spark_name_transaction_view.dart | 20 +- .../transaction_views/edit_note_view.dart | 173 ++++--- lib/services/transaction_note_service.dart | 27 + .../transaction_note_service_test.dart | 157 ++++++ 10 files changed, 627 insertions(+), 395 deletions(-) create mode 100644 lib/services/transaction_note_service.dart create mode 100644 test/services/transaction_note_service_test.dart diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 3eeca3f19d..6bc5f4b89e 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -324,7 +324,9 @@ class MainDB { await isar.writeTxn(() async { final set = utxos.toSet(); + final noteValues = {}; for (final utxo in utxos) { + UTXO persistedUtxo = utxo; // check if utxo exists in db and update accordingly final storedUtxo = await isar.utxos .where() @@ -342,24 +344,36 @@ class MainDB { !storedUtxo.isBlocked && !storedUtxo.userUnfroze; set.remove(utxo); - set.add( - storedUtxo.copyWith( - value: utxo.value, - address: utxo.address, - blockTime: utxo.blockTime, - blockHeight: utxo.blockHeight, - blockHash: utxo.blockHash, - // passing null keeps the stored value - isBlocked: applyAutoBlock ? true : null, - blockedReason: applyAutoBlock ? utxo.blockedReason : null, - name: applyAutoBlock && storedUtxo.name.isEmpty - ? utxo.name - : null, - ), + persistedUtxo = storedUtxo.copyWith( + value: utxo.value, + address: utxo.address, + blockTime: utxo.blockTime, + blockHeight: utxo.blockHeight, + blockHash: utxo.blockHash, + // passing null keeps the stored value + isBlocked: applyAutoBlock ? true : null, + blockedReason: applyAutoBlock ? utxo.blockedReason : null, + name: applyAutoBlock && storedUtxo.name.isEmpty ? utxo.name : null, ); + set.add(persistedUtxo); } else { newUTXO = true; } + + if (persistedUtxo.name.isEmpty) { + final noteValue = noteValues.containsKey(utxo.txid) + ? noteValues[utxo.txid] + : (await isar.transactionNotes.getByTxidWalletId( + utxo.txid, + walletId, + ))?.value; + noteValues[utxo.txid] = noteValue; + if (noteValue?.isNotEmpty == true) { + set + ..remove(persistedUtxo) + ..add(persistedUtxo.copyWith(name: noteValue)); + } + } } await isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); @@ -381,42 +395,37 @@ class MainDB { isar.transactionNotes.where().walletIdEqualTo(walletId); Future putTransactionNote(TransactionNote transactionNote) => - isar.writeTxn(() async { - await isar.transactionNotes.put(transactionNote); - }); - - /// Auto-labels UTXOs associated with a transaction note. - /// Only labels UTXOs that don't already have a user-set name. - Future autoLabelUTXOsFromNote(TransactionNote note) async { - if (note.value.isEmpty) return; - - final utxos = await isar.utxos - .where() - .walletIdEqualTo(note.walletId) - .filter() - .txidEqualTo(note.txid) - .findAll(); - - if (utxos.isEmpty) return; - - final toUpdate = []; - for (final utxo in utxos) { - if (utxo.name.isEmpty) { - toUpdate.add(utxo.copyWith(name: note.value)); - } - } - - if (toUpdate.isNotEmpty) { - await isar.writeTxn(() async { - await isar.utxos.putAll(toUpdate); - }); - } - } + putTransactionNotes([transactionNote]); + /// Copies a note only to blank UTXO labels. The label is independent after + /// that first assignment, so later note edits cannot overwrite it. Future putTransactionNotes(List transactionNotes) => - isar.writeTxn(() async { - await isar.transactionNotes.putAll(transactionNotes); - }); + transactionNotes.isEmpty + ? Future.value() + : isar.writeTxn(() async { + await isar.transactionNotes.putAll(transactionNotes); + + final toUpdate = []; + for (final note in transactionNotes) { + if (note.value.isEmpty) { + continue; + } + final utxos = await isar.utxos + .where() + .walletIdEqualTo(note.walletId) + .filter() + .txidEqualTo(note.txid) + .findAll(); + toUpdate.addAll( + utxos + .where((utxo) => utxo.name.isEmpty) + .map((utxo) => utxo.copyWith(name: note.value)), + ); + } + if (toUpdate.isNotEmpty) { + await isar.utxos.putAll(toUpdate); + } + }); Future getTransactionNote( String walletId, diff --git a/lib/pages/cakepay/cakepay_confirm_send_view.dart b/lib/pages/cakepay/cakepay_confirm_send_view.dart index 41ea7a14bf..e8ddb01c75 100644 --- a/lib/pages/cakepay/cakepay_confirm_send_view.dart +++ b/lib/pages/cakepay/cakepay_confirm_send_view.dart @@ -8,6 +8,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/constants.dart'; @@ -95,11 +96,10 @@ class _CakePayConfirmSendViewState txid = (results.first as TxData).txid!; - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, + ); if (context.mounted) { // pop sending dialog (pushed via showDialog which uses root navigator) diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index 5b7850c1ae..dc1b76a41f 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -21,6 +21,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -135,14 +136,10 @@ class _ConfirmChangeNowSendViewState txid = (results.first as TxData).txid!; - // save note - final txNote = TransactionNote( - walletId: walletId, - txid: txid, - value: note, + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, ); - await ref.read(mainDBProvider).putTransactionNote(txNote); - await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); await ref .read(tradeSentFromStackLookupProvider) diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index baf5a05992..5d46f77512 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -24,6 +24,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/global/secure_store_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -140,16 +141,18 @@ class _ConfirmNameTransactionViewState ref.refresh(desktopUseUTXOs); } - // save note - for (final txid in txids) { - final txNote = TransactionNote( - walletId: walletId, - txid: txid, - value: note, - ); - await ref.read(mainDBProvider).putTransactionNote(txNote); - await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); unawaited(wallet.refresh()); @@ -227,9 +230,10 @@ class _ConfirmNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: + Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -274,76 +278,81 @@ class _ConfirmNameTransactionViewState return ConditionalParent( condition: !isDesktop, - builder: (child) => Background( - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - // if (FocusScope.of(context).hasFocus) { - // FocusScope.of(context).unfocus(); - // await Future.delayed(Duration(milliseconds: 50)); - // } - Navigator.of(context).pop(); - }, - ), - title: Text( - "Confirm transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only(left: 12, top: 12, right: 12), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, + builder: + (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + backgroundColor: + Theme.of(context).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + // if (FocusScope.of(context).hasFocus) { + // FocusScope.of(context).unfocus(); + // await Future.delayed(Duration(milliseconds: 50)); + // } + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only( + left: 12, + top: 12, + right: 12, ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, + ), + ), ), ), - ), - ), - ); - }, + ); + }, + ), + ), ), ), - ), - ), child: ConditionalParent( condition: isDesktop, - builder: (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + builder: + (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - AppBarBackButton( - size: 40, - iconSize: 24, - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(), - ), - Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), + Row( + children: [ + AppBarBackButton( + size: 40, + iconSize: 24, + onPressed: + () => + Navigator.of(context, rootNavigator: true).pop(), + ), + Text( + "Confirm transaction", + style: STextStyles.desktopH3(context), + ), + ], ), + Flexible(child: SingleChildScrollView(child: child)), ], ), - Flexible(child: SingleChildScrollView(child: child)), - ], - ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -483,18 +492,18 @@ class _ConfirmNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: + Theme.of(context).extension()!.background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: + Theme.of( + context, + ).extension()!.background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -546,23 +555,24 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.name, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: + Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: + Theme.of( + context, + ).extension()!.background, ), Padding( padding: const EdgeInsets.all(12), @@ -579,14 +589,14 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.value, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: + Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -604,12 +614,14 @@ class _ConfirmNameTransactionViewState children: [ SelectableText( "Note (optional)", - style: STextStyles.desktopTextExtraSmall(context) - .copyWith( - color: Theme.of(context) + style: STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of(context) .extension()! .textFieldActiveSearchIconRight, - ), + ), textAlign: TextAlign.left, ), const SizedBox(height: 10), @@ -624,48 +636,49 @@ class _ConfirmNameTransactionViewState enableSuggestions: isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: STextStyles.desktopTextExtraSmall(context) - .copyWith( - color: Theme.of( + style: STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), + height: 1.8, + ), onChanged: (_) => setState(() {}), - decoration: - standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: noteController.text.isNotEmpty + decoration: standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: + noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState( - () => - noteController.text = "", - ); - }, - ), - ], - ), + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState( + () => noteController.text = "", + ); + }, + ), + ], ), - ) + ), + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -689,9 +702,10 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: + Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -702,17 +716,21 @@ class _ConfirmNameTransactionViewState String fiatAmount = "N/A"; if (externalCalls) { - final price = ref - .read(priceAnd24hChangeNotifierProvider) - .getPrice(coin) - ?.value; + final price = + ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + ?.value; if (price != null && price > Decimal.zero) { fiatAmount = (amountWithoutChange.decimal * price) .toAmount(fractionDigits: 2) .fiatString( - locale: ref - .read(localeServiceChangeNotifierProvider) - .locale, + locale: + ref + .read( + localeServiceChangeNotifierProvider, + ) + .locale, ); } } @@ -757,9 +775,10 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: + Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -783,9 +802,10 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: + Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -812,9 +832,10 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: + Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -824,52 +845,64 @@ class _ConfirmNameTransactionViewState if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 23 : 12), Padding( - padding: isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: + isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: RoundedContainer( - padding: isDesktop - ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) - : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + padding: + isDesktop + ? const EdgeInsets.symmetric( + horizontal: 16, + vertical: 18, + ) + : const EdgeInsets.all(12), + color: + Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.titleBold12(context).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: + Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.titleBold12(context).copyWith( + color: + Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), ), SelectableText( ref .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: + Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: + Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), textAlign: TextAlign.right, ), ], @@ -878,9 +911,10 @@ class _ConfirmNameTransactionViewState ), SizedBox(height: isDesktop ? 28 : 16), Padding( - padding: isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: + isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: PrimaryButton( label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, @@ -890,27 +924,28 @@ class _ConfirmNameTransactionViewState if (isDesktop) { unlocked = await showDialog( context: context, - builder: (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), + builder: + (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: DesktopAuthSend(coin: coin), + ), + ], ), - ], - ), - ), + ), ); } else { unlocked = await Navigator.push( @@ -918,16 +953,18 @@ class _ConfirmNameTransactionViewState RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to send transaction", - biometricsAuthenticationTitle: "Confirm Transaction", - ), + builder: + (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to send transaction", + biometricsAuthenticationTitle: + "Confirm Transaction", + ), settings: const RouteSettings( name: "/confirmsendlockscreen", ), @@ -943,9 +980,10 @@ class _ConfirmNameTransactionViewState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: Util.isDesktop - ? "Invalid passphrase" - : "Invalid PIN", + message: + Util.isDesktop + ? "Invalid passphrase" + : "Invalid PIN", context: context, ), ); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index d171c0a2ac..e7973a7fea 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -29,6 +29,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -458,16 +459,15 @@ class _ConfirmTransactionViewState ref.refresh(desktopUseUTXOs); } - // save note - for (final txid in txids) { - final txNote = TransactionNote( - walletId: walletId, - txid: txid, - value: note, - ); - await ref.read(mainDBProvider).putTransactionNote(txNote); - await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => + TransactionNote(walletId: walletId, txid: txid, value: note), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); if (widget.isTokenTx) { if (wallet is SolanaWallet) { diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart index 83781fdf24..33f6066205 100644 --- a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart +++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart @@ -9,6 +9,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -113,12 +114,10 @@ class _ShopInBitConfirmSendViewState txid = (results.first as TxData).txid!; - // save note - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, + ); // The server (and the BTCPay webhook) own ticket + payment state from // here, so there's nothing to persist locally; just nudge a refresh so @@ -132,9 +131,7 @@ class _ShopInBitConfirmSendViewState final popThroughRouteName = widget.popThroughRouteName; if (popThroughRouteName != null) { final navigator = Navigator.of(context, rootNavigator: true); - navigator.popUntil( - ModalRoute.withName(popThroughRouteName), - ); + navigator.popUntil(ModalRoute.withName(popThroughRouteName)); navigator.pop(); } else { // pop sending dialog (pushed via showDialog which uses root navigator) diff --git a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart index 7414842b26..a9c5b2f83d 100644 --- a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart +++ b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart @@ -22,6 +22,7 @@ import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialo import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -119,16 +120,15 @@ class _ConfirmSparkNameTransactionViewState txids.addAll(txData.sparkSpends?.map((e) => e.txid!) ?? [txData.txid!]); ref.refresh(desktopUseUTXOs); - // save note - for (final txid in txids) { - final txNote = TransactionNote( - walletId: walletId, - txid: txid, - value: note, - ); - await ref.read(mainDBProvider).putTransactionNote(txNote); - await ref.read(mainDBProvider).autoLabelUTXOsFromNote(txNote); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => + TransactionNote(walletId: walletId, txid: txid, value: note), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); final address = txData.sparkNameInfo?.sparkAddress; final currentReceiving = await wallet.getCurrentReceivingSparkAddress(); diff --git a/lib/pages/wallet_view/transaction_views/edit_note_view.dart b/lib/pages/wallet_view/transaction_views/edit_note_view.dart index 248e4531f6..bcb6202ec3 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -71,33 +71,34 @@ class _EditNoteViewState extends ConsumerState { condition: !isDesktop, builder: (child) => Background(child: child), child: Scaffold( - backgroundColor: isDesktop - ? Colors.transparent - : Theme.of(context).extension()!.background, - appBar: isDesktop - ? null - : AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Edit note", - style: STextStyles.navBarTitle(context), + backgroundColor: + isDesktop + ? Colors.transparent + : Theme.of(context).extension()!.background, + appBar: + isDesktop + ? null + : AppBar( + backgroundColor: + Theme.of(context).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Edit note", + style: STextStyles.navBarTitle(context), + ), ), - ), body: MobileEditNoteScaffold( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -114,9 +115,10 @@ class _EditNoteViewState extends ConsumerState { ), ), Padding( - padding: isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: + isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -125,50 +127,55 @@ class _EditNoteViewState extends ConsumerState { autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, controller: _noteController, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( + style: + isDesktop + ? STextStyles.desktopTextExtraSmall( context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), + ).copyWith( + color: + Theme.of(context) + .extension()! + .textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), focusNode: noteFieldFocusNode, - decoration: - standardInputDecoration( - "Note", - noteFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: isDesktop + decoration: standardInputDecoration( + "Note", + noteFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: + isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: _noteController.text.isNotEmpty + suffixIcon: + _noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _noteController.text = ""; - }); - }, - ), - ], - ), + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _noteController.text = ""; + }); + }, + ), + ], ), - ) + ), + ) : null, - ), + ), ), ), ), @@ -180,17 +187,16 @@ class _EditNoteViewState extends ConsumerState { child: PrimaryButton( label: "Save", onPressed: () async { - final note = - _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, - ); - await ref.read(mainDBProvider).putTransactionNote(note); await ref .read(mainDBProvider) - .autoLabelUTXOsFromNote(note); + .putTransactionNote( + _note?.copyWith(value: _noteController.text) ?? + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ), + ); if (mounted) { Navigator.of(context).pop(); @@ -201,15 +207,16 @@ class _EditNoteViewState extends ConsumerState { if (!isDesktop) TextButton( onPressed: () async { - final note = - _note?.copyWith(value: _noteController.text) ?? - TransactionNote( - walletId: widget.walletId, - txid: widget.txid, - value: _noteController.text, + await ref + .read(mainDBProvider) + .putTransactionNote( + _note?.copyWith(value: _noteController.text) ?? + TransactionNote( + walletId: widget.walletId, + txid: widget.txid, + value: _noteController.text, + ), ); - await ref.read(mainDBProvider).putTransactionNote(note); - await ref.read(mainDBProvider).autoLabelUTXOsFromNote(note); if (mounted) { Navigator.of(context).pop(); } diff --git a/lib/services/transaction_note_service.dart b/lib/services/transaction_note_service.dart new file mode 100644 index 0000000000..b283d692dc --- /dev/null +++ b/lib/services/transaction_note_service.dart @@ -0,0 +1,27 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import '../models/isar/models/transaction_note.dart'; +import '../utilities/logger.dart'; + +Future saveTransactionNotesAfterSend({ + required List notes, + required Future Function(List) persist, +}) async { + try { + await persist(notes); + return true; + } catch (e, s) { + Logging.instance.w( + "Transaction sent, but its note could not be saved", + error: e, + stackTrace: s, + ); + return false; + } +} diff --git a/test/services/transaction_note_service_test.dart b/test/services/transaction_note_service_test.dart new file mode 100644 index 0000000000..8fda9682b3 --- /dev/null +++ b/test/services/transaction_note_service_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/services/transaction_note_service.dart'; + +void main() { + const walletId = "wallet-1"; + late Directory tempDir; + late Isar isar; + final db = MainDB.instance; + + UTXO utxo({ + required String txid, + String wallet = walletId, + int vout = 0, + int value = 1000, + String name = "", + }) => UTXO( + walletId: wallet, + txid: txid, + vout: vout, + value: value, + name: name, + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: "block", + blockHeight: 1, + blockTime: 1, + ); + + TransactionNote note(String txid, String value) => + TransactionNote(walletId: walletId, txid: txid, value: value); + + UTXO stored(String txid, int vout, {String wallet = walletId}) => isar.utxos + .where() + .txidWalletIdVoutEqualTo(txid, wallet, vout) + .findFirstSync()!; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp("stack-note-test-"); + isar = await Isar.open( + [TransactionNoteSchema, UTXOSchema], + directory: tempDir.path, + name: "transaction_note_test", + ); + await db.initMainDB(mock: isar); + }); + + setUp(() async { + await isar.writeTxn(() async { + await isar.transactionNotes.clear(); + await isar.utxos.clear(); + }); + }); + + tearDownAll(() async { + await isar.close(deleteFromDisk: true); + await tempDir.delete(recursive: true); + }); + + test("labels outputs that arrive after their note", () async { + await db.putTransactionNote(note("tx-1", "exchange")); + + await db.updateUTXOs(walletId, [ + utxo(txid: "tx-1"), + utxo(txid: "tx-1", vout: 1, name: "manual"), + ]); + + expect(stored("tx-1", 0).name, "exchange"); + expect(stored("tx-1", 1).name, "manual"); + }); + + test("labels existing blank outputs when a note is saved", () async { + await db.updateUTXOs(walletId, [utxo(txid: "tx-2")]); + + await db.putTransactionNote(note("tx-2", "salary")); + + expect(stored("tx-2", 0).name, "salary"); + }); + + test("later note edits preserve existing output labels", () async { + await db.updateUTXOs(walletId, [ + utxo(txid: "tx-3"), + utxo(txid: "tx-3", vout: 1, name: "manual"), + ]); + await db.putTransactionNote(note("tx-3", "first")); + + await db.putTransactionNote(note("tx-3", "second")); + + expect(stored("tx-3", 0).name, "first"); + expect(stored("tx-3", 1).name, "manual"); + }); + + test("wallet refreshes preserve an inherited label", () async { + await db.putTransactionNote(note("tx-refresh", "savings")); + await db.updateUTXOs(walletId, [utxo(txid: "tx-refresh")]); + + await db.updateUTXOs(walletId, [utxo(txid: "tx-refresh", value: 1200)]); + + expect(stored("tx-refresh", 0).name, "savings"); + expect(stored("tx-refresh", 0).value, 1200); + }); + + test("refresh labels legacy blank outputs with an existing note", () async { + await isar.writeTxn(() async { + await isar.transactionNotes.put(note("tx-legacy", "legacy")); + await isar.utxos.put(utxo(txid: "tx-legacy")); + }); + + await db.updateUTXOs(walletId, [utxo(txid: "tx-legacy")]); + + expect(stored("tx-legacy", 0).name, "legacy"); + }); + + test("blank notes do not label outputs", () async { + await db.putTransactionNote(note("tx-4", "")); + await db.updateUTXOs(walletId, [utxo(txid: "tx-4")]); + + expect(stored("tx-4", 0).name, isEmpty); + }); + + test("notes never cross wallet boundaries", () async { + await db.putTransactionNote(note("shared-txid", "private")); + + await db.updateUTXOs("wallet-2", [ + utxo(txid: "shared-txid", wallet: "wallet-2"), + ]); + + expect(stored("shared-txid", 0, wallet: "wallet-2").name, isEmpty); + }); + + test("post-send note failures do not report a send failure", () async { + final saved = await saveTransactionNotesAfterSend( + notes: [note("tx-5", "gift")], + persist: (_) async => throw StateError("disk full"), + ); + + expect(saved, isFalse); + }); + + test("post-send note persistence receives the complete batch", () async { + List? persisted; + final notes = [note("tx-6", "one"), note("tx-7", "two")]; + + final saved = await saveTransactionNotesAfterSend( + notes: notes, + persist: (value) async => persisted = value, + ); + + expect(saved, isTrue); + expect(persisted, same(notes)); + }); +}