From 050066d5da83bed1d3c4e257904d6e61d21fe754 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:05:50 -0500 Subject: [PATCH 1/2] style: format the transaction and trade note editors dart format only; no behaviour change. --- .../exchange_view/edit_trade_note_view.dart | 63 ++++---- .../transaction_views/edit_note_view.dart | 139 +++++++++--------- 2 files changed, 99 insertions(+), 103 deletions(-) diff --git a/lib/pages/exchange_view/edit_trade_note_view.dart b/lib/pages/exchange_view/edit_trade_note_view.dart index 1406d87e3b..8b2594379c 100644 --- a/lib/pages/exchange_view/edit_trade_note_view.dart +++ b/lib/pages/exchange_view/edit_trade_note_view.dart @@ -62,8 +62,9 @@ class _EditNoteViewState extends ConsumerState { child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { @@ -102,41 +103,43 @@ class _EditNoteViewState extends ConsumerState { ), child: TextField( autocorrect: Util.isDesktop ? false : true, - enableSuggestions: - Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, controller: _noteController, style: STextStyles.field(context), focusNode: noteFieldFocusNode, onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Note", - noteFieldFocusNode, - context, - ).copyWith( - suffixIcon: - _noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Note", + noteFieldFocusNode, + context, + ).copyWith( + 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 Spacer(), 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..2589cc7a17 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, - ), + ), ), ), ), From 40cc4f04427371fa55c225ac219658804809fd9c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:06:03 -0500 Subject: [PATCH 2/2] fix: support multiple lines in transaction note editing Transaction and trade notes were single-line fields that finalised the edit on Enter, so a note could never contain a line break. Give both a three to six line multiline field. On desktop the editor is hosted in a fixed height DesktopDialog, so scroll its body rather than let the grown field and the Save button paint outside the dialog card. Guards after the save await now use context.mounted. --- .../exchange_view/edit_trade_note_view.dart | 8 +- .../transaction_views/edit_note_view.dart | 18 +- test/pages/edit_note_views_test.dart | 285 ++++++++++++++++++ 3 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 test/pages/edit_note_views_test.dart diff --git a/lib/pages/exchange_view/edit_trade_note_view.dart b/lib/pages/exchange_view/edit_trade_note_view.dart index 8b2594379c..fe46ca8b89 100644 --- a/lib/pages/exchange_view/edit_trade_note_view.dart +++ b/lib/pages/exchange_view/edit_trade_note_view.dart @@ -71,7 +71,7 @@ class _EditNoteViewState extends ConsumerState { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 75)); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -106,6 +106,10 @@ class _EditNoteViewState extends ConsumerState { enableSuggestions: Util.isDesktop ? false : true, + minLines: 3, + maxLines: 6, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, controller: _noteController, style: STextStyles.field(context), focusNode: noteFieldFocusNode, @@ -151,7 +155,7 @@ class _EditNoteViewState extends ConsumerState { tradeId: widget.tradeId, note: _noteController.text, ); - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, 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 2589cc7a17..6424658ffd 100644 --- a/lib/pages/wallet_view/transaction_views/edit_note_view.dart +++ b/lib/pages/wallet_view/transaction_views/edit_note_view.dart @@ -88,7 +88,7 @@ class _EditNoteViewState extends ConsumerState { const Duration(milliseconds: 75), ); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -124,6 +124,10 @@ class _EditNoteViewState extends ConsumerState { child: TextField( autocorrect: Util.isDesktop ? false : true, enableSuggestions: Util.isDesktop ? false : true, + minLines: 3, + maxLines: 6, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, controller: _noteController, style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( @@ -172,8 +176,8 @@ class _EditNoteViewState extends ConsumerState { ), ), ), - // if (!isDesktop) - const Spacer(), + if (!isDesktop) const Spacer(), + if (isDesktop) const SizedBox(height: 12), if (isDesktop) Padding( padding: const EdgeInsets.all(32), @@ -191,7 +195,7 @@ class _EditNoteViewState extends ConsumerState { ), ); - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -210,7 +214,7 @@ class _EditNoteViewState extends ConsumerState { value: _noteController.text, ), ); - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -235,7 +239,9 @@ class MobileEditNoteScaffold extends StatelessWidget { @override Widget build(BuildContext context) { if (Util.isDesktop) { - return child; + // Desktop callers host this view in a DesktopDialog of fixed height, + // which a multi-line note field can exceed. + return SingleChildScrollView(child: child); } else { return SafeArea( child: Padding( diff --git a/test/pages/edit_note_views_test.dart b/test/pages/edit_note_views_test.dart new file mode 100644 index 0000000000..80d6dcb5e3 --- /dev/null +++ b/test/pages/edit_note_views_test.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/transaction_note.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/edit_trade_note_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/edit_note_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/exchange/trade_note_service_provider.dart'; +import 'package:stackwallet/providers/wallet/transaction_note_provider.dart'; +import 'package:stackwallet/services/trade_notes_service.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; + +import '../sample_data/theme_json.dart'; + +void main() { + late double? originalScreenWidth; + + setUp(() { + originalScreenWidth = Util.screenWidth; + Util.screenWidth = 400; + }); + + tearDown(() { + Util.screenWidth = originalScreenWidth; + }); + + ThemeData buildTheme() { + return ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ); + } + + ThemeService buildThemeService() => + _TestThemeService(StackTheme.fromJson(json: lightThemeJsonMap)); + + testWidgets('trade notes accept and save multiple lines', (tester) async { + final service = _RecordingTradeNotesService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + tradeNoteServiceProvider.overrideWithValue(service), + ], + child: MaterialApp( + theme: buildTheme(), + home: const EditTradeNoteView(tradeId: 'trade-id', note: ''), + ), + ), + ); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.minLines, 3); + expect(field.maxLines, 6); + expect(field.keyboardType, TextInputType.multiline); + expect(field.textInputAction, TextInputAction.newline); + + await tester.enterText(find.byType(TextField), 'first line\nsecond line'); + await tester.tap(find.text('Save')); + await tester.pump(); + + expect(service.savedTradeId, 'trade-id'); + expect(service.savedNote, 'first line\nsecond line'); + }); + + testWidgets('transaction notes accept and save multiple lines', ( + tester, + ) async { + final db = _RecordingMainDB(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + mainDBProvider.overrideWithValue(db), + pTransactionNote.overrideWithProvider( + (key) => Provider((ref) => null), + ), + ], + child: MaterialApp( + theme: buildTheme(), + home: const EditNoteView(txid: 'txid', walletId: 'wallet-id'), + ), + ), + ); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField)); + expect(field.minLines, 3); + expect(field.maxLines, 6); + expect(field.keyboardType, TextInputType.multiline); + expect(field.textInputAction, TextInputAction.newline); + + await tester.enterText(find.byType(TextField), 'first line\nsecond line'); + await tester.tap(find.text('Save')); + await tester.pump(); + + final saved = db.savedNote!; + expect(saved.walletId, 'wallet-id'); + expect(saved.txid, 'txid'); + expect(saved.value, 'first line\nsecond line'); + }); + + // Desktop opens both editors inside a fixed size DesktopDialog; every call + // site uses 580x360. + Future pumpInDesktopDialog( + WidgetTester tester, { + required List overrides, + required Widget child, + }) async { + Util.screenWidth = null; + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: buildTheme(), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => DesktopDialog( + maxWidth: 580, + maxHeight: 360, + child: child, + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + /// A note taller than the dialog must scroll inside it: no overflow, and Save + /// still painted within the dialog card. + Future expectNoteFitsDialog(WidgetTester tester, String note) async { + await tester.enterText(find.byType(TextField), note); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final card = tester.getRect( + find + .descendant( + of: find.byType(DesktopDialog), + matching: find.byType(Material), + ) + .first, + ); + final scrollable = tester.state( + find + .descendant( + of: find.byType(DesktopDialog), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pumpAndSettle(); + + final save = tester.getRect(find.text('Save')); + expect(save.top, greaterThanOrEqualTo(card.top)); + expect(save.bottom, lessThanOrEqualTo(card.bottom)); + } + + for (final lines in [1, 3, 4, 6]) { + testWidgets('desktop transaction note editor fits $lines line(s)', ( + tester, + ) async { + await pumpInDesktopDialog( + tester, + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + mainDBProvider.overrideWithValue(_RecordingMainDB()), + pTransactionNote.overrideWithProvider( + (key) => Provider((ref) => null), + ), + ], + child: const EditNoteView(txid: 'txid', walletId: 'wallet-id'), + ); + + await expectNoteFitsDialog( + tester, + List.generate(lines, (i) => 'line $i').join('\n'), + ); + }); + } + + testWidgets('desktop transaction note editor fits a wrapped paragraph', ( + tester, + ) async { + await pumpInDesktopDialog( + tester, + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + mainDBProvider.overrideWithValue(_RecordingMainDB()), + pTransactionNote.overrideWithProvider((key) => Provider((ref) => null)), + ], + child: const EditNoteView(txid: 'txid', walletId: 'wallet-id'), + ); + + // No newline at all: soft wrapping alone grows the field to maxLines. + await expectNoteFitsDialog(tester, List.filled(80, 'word').join(' ')); + }); + + testWidgets('desktop transaction note editor fits a very long note', ( + tester, + ) async { + await pumpInDesktopDialog( + tester, + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + mainDBProvider.overrideWithValue(_RecordingMainDB()), + pTransactionNote.overrideWithProvider((key) => Provider((ref) => null)), + ], + child: const EditNoteView(txid: 'txid', walletId: 'wallet-id'), + ); + + await expectNoteFitsDialog( + tester, + List.generate(400, (i) => 'line $i ${'x' * 40}').join('\n'), + ); + }); + + testWidgets('desktop trade note editor fits six lines', (tester) async { + await pumpInDesktopDialog( + tester, + overrides: [ + pThemeService.overrideWithValue(buildThemeService()), + tradeNoteServiceProvider.overrideWithValue( + _RecordingTradeNotesService(), + ), + ], + child: const EditTradeNoteView(tradeId: 'trade-id', note: ''), + ); + + await expectNoteFitsDialog( + tester, + List.generate(6, (i) => 'line $i').join('\n'), + ); + }); +} + +class _TestThemeService extends Fake implements ThemeService { + _TestThemeService(this.theme); + + final StackTheme theme; + + @override + StackTheme? getTheme({required String themeId}) => theme; +} + +class _RecordingMainDB extends Fake implements MainDB { + TransactionNote? savedNote; + + @override + Future putTransactionNote(TransactionNote transactionNote) async { + savedNote = transactionNote; + } +} + +class _RecordingTradeNotesService extends TradeNotesService { + String? savedTradeId; + String? savedNote; + + @override + Future set({required String tradeId, required String note}) async { + savedTradeId = tradeId; + savedNote = note; + } +}