Merge pull request #1361 from cypherstack/fix/305-multiline-transaction-notes
What changed, and why it matters
This commit lets users type multi-line notes when editing transaction and trade notes, and fixes the desktop layout so long notes scroll instead of breaking the screen. It also swaps the old `mounted` check for the newer `context.mounted` pattern. There is no security issue here; it is a straightforward user-interface improvement with added tests.
No security action needed. Treat as a normal feature/bug-fix review; verify the new tests pass and that multi-line notes display correctly across mobile and desktop.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch changes two Flutter note-editing screens to set minLines: 3, maxLines: 6, keyboardType: TextInputType.multiline, and textInputAction: TextInputAction.newline, enabling newline characters in notes. It wraps the desktop editor body in a SingleChildScrollView to prevent overflow inside fixed-size DesktopDialog windows, and replaces if (mounted) with if (context.mounted) before Navigator.of(context).pop() after async gaps. A new test file verifies multi-line input is accepted, saved, and fits within the 580x360 desktop dialog without overflow.
Changed components
lib/pages/exchange_view/edit_trade_note_view.dartlib/pages/wallet_view/transaction_views/edit_note_view.darttest/pages/edit_note_views_test.dartInspect captured patch +402 / −111
### lib/pages/exchange_view/edit_trade_note_view.dart
@@ -62,15 +62,16 @@ class _EditNoteViewState extends ConsumerState<EditTradeNoteView> {
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
- backgroundColor:
- Theme.of(context).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(const Duration(milliseconds: 75));
}
- if (mounted) {
+ if (context.mounted) {
Navigator.of(context).pop();
}
},
@@ -102,41 +103,47 @@ class _EditNoteViewState extends ConsumerState<EditTradeNoteView> {
),
child: TextField(
autocorrect: Util.isDesktop ? false : true,
- enableSuggestions:
- Util.isDesktop ? false : true,
+ enableSuggestions: Util.isDesktop
+ ? false
+ : true,
+ minLines: 3,
+ maxLines: 6,
+ keyboardType: TextInputType.multiline,
+ textInputAction: TextInputAction.newline,
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(),
@@ -148,7 +155,7 @@ class _EditNoteViewState extends ConsumerState<EditTradeNoteView> {
tradeId: widget.tradeId,
note: _noteController.text,
);
- if (mounted) {
+ if (context.mounted) {
Navigator.of(context).pop();
}
},
### lib/pages/wallet_view/transaction_views/edit_note_view.dart
@@ -71,34 +71,33 @@ class _EditNoteViewState extends ConsumerState<EditNoteView> {
condition: !isDesktop,
builder: (child) => Background(child: child),
child: Scaffold(
- backgroundColor:
- isDesktop
- ? Colors.transparent
- : Theme.of(context).extension<StackColors>()!.background,
- appBar:
- isDesktop
- ? null
- : AppBar(
- backgroundColor:
- Theme.of(context).extension<StackColors>()!.background,
- leading: AppBarBackButton(
- onPressed: () async {
- if (FocusScope.of(context).hasFocus) {
- FocusScope.of(context).unfocus();
- await Future<void>.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<StackColors>()!.background,
+ appBar: isDesktop
+ ? null
+ : AppBar(
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
+ leading: AppBarBackButton(
+ onPressed: () async {
+ if (FocusScope.of(context).hasFocus) {
+ FocusScope.of(context).unfocus();
+ await Future<void>.delayed(
+ const Duration(milliseconds: 75),
+ );
+ }
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+ },
),
+ title: Text(
+ "Edit note",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
body: MobileEditNoteScaffold(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -115,72 +114,70 @@ class _EditNoteViewState extends ConsumerState<EditNoteView> {
),
),
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,
),
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(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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,
- ),
+ ),
),
),
),
- // if (!isDesktop)
- const Spacer(),
+ if (!isDesktop) const Spacer(),
+ if (isDesktop) const SizedBox(height: 12),
if (isDesktop)
Padding(
padding: const EdgeInsets.all(32),
@@ -198,7 +195,7 @@ class _EditNoteViewState extends ConsumerState<EditNoteView> {
),
);
- if (mounted) {
+ if (context.mounted) {
Navigator.of(context).pop();
}
},
@@ -217,7 +214,7 @@ class _EditNoteViewState extends ConsumerState<EditNoteView> {
value: _noteController.text,
),
);
- if (mounted) {
+ if (context.mounted) {
Navigator.of(context).pop();
}
},
@@ -242,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(
### 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<TextField>(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<TextField>(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<void> pumpInDesktopDialog(
+ WidgetTester tester, {
+ required List<Override> 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<void>(
+ 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<void> 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<ScrollableState>(
+ 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<void> putTransactionNote(TransactionNote transactionNote) async {
+ savedNote = transactionNote;
+ }
+}
+
+class _RecordingTradeNotesService extends TradeNotesService {
+ String? savedTradeId;
+ String? savedNote;
+
+ @override
+ Future<void> set({required String tradeId, required String note}) async {
+ savedTradeId = tradeId;
+ savedNote = note;
+ }
+}Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.