From 85a45daaeee4a2a3ce28b5ed2d2710e624f38ab4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 17:48:39 +0200 Subject: [PATCH] Share one note form between capture and the detail screen Editing an existing note looked nothing like capturing a new one: it always showed the template dropdown, the View/Guided/Raw pills and the priority/status row. The cause was one missing argument -- both screens already embedded the same NoteEditor, but NoteDetailScreen never passed advancedMode, which defaults to true in the widget while the app-wide AppSettings.advancedMode defaults to false. Extract the shared form as three single-purpose components rather than one widget owning several jobs: - note_meta_row.dart renders the priority/status row (was capture_meta_row.dart; drops the private _MetaDropdown duplicate the detail screen carried, which used DropdownButtonFormField where the shared one uses DropdownButton). - note_form_chrome_gate.dart owns chrome visibility and nothing else, replacing the _chromeVisible flag both screens copy-pasted. - note_form.dart composes those two with NoteEditor. appSettings is now a required parameter through NotesListScreen and NoteDetailScreen. Required, not defaulted: a silent default is exactly what caused this, so it must not be reintroducible. Also in the capture app bar: drop the decorative Icons.list_alt_outlined beside the note count, since the adjacent Icons.list button already names what is counted, and move the count to the right of that button. The finalize-and-clear action becomes Icons.check instead of Icons.add -- it never saved anything (every keystroke is already persisted), so a plus sign misdescribed it. 314 tests, 100% line coverage. Verified in a running web build that edit and capture render identically with advanced mode both off and on. Co-Authored-By: Claude Opus 4.8 --- lib/ui/capture_app_bar.dart | 35 +++--- lib/ui/capture_screen.dart | 13 +- lib/ui/capture_screen_sync.dart | 50 +++----- lib/ui/note_detail_screen.dart | 113 ++++-------------- lib/ui/note_form.dart | 99 +++++++++++++++ lib/ui/note_form_chrome_gate.dart | 58 +++++++++ ...pture_meta_row.dart => note_meta_row.dart} | 25 ++-- lib/ui/notes_list_navigation.dart | 8 +- lib/ui/notes_list_screen.dart | 19 ++- pubspec.yaml | 2 +- test/capture_screen_sync_test.dart | 8 +- test/note_detail_screen_test.dart | 41 ++++++- test/notes_list_harness.dart | 9 +- 13 files changed, 299 insertions(+), 181 deletions(-) create mode 100644 lib/ui/note_form.dart create mode 100644 lib/ui/note_form_chrome_gate.dart rename lib/ui/{capture_meta_row.dart => note_meta_row.dart} (79%) diff --git a/lib/ui/capture_app_bar.dart b/lib/ui/capture_app_bar.dart index 33bebad..78ca419 100644 --- a/lib/ui/capture_app_bar.dart +++ b/lib/ui/capture_app_bar.dart @@ -68,30 +68,10 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { ), ), // coverage:ignore-end - // Live count of stored notes, proving local persistence. - StreamBuilder( - stream: repository.watchCount(), - builder: (context, snapshot) { - final count = snapshot.data ?? 0; - return Padding( - padding: const EdgeInsets.only(right: 4), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.list_alt_outlined, size: 18), - const SizedBox(width: 4), - Text('$count'), - ], - ), - ), - ); - }, - ), IconButton( tooltip: 'New note', onPressed: onNewNote, - icon: const Icon(Icons.add), + icon: const Icon(Icons.check), ), if (advanced) IconButton( @@ -110,6 +90,19 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { onPressed: onOpenList, icon: const Icon(Icons.list), ), + // Live count of stored notes, proving local persistence. Sits directly + // right of the Notes button it belongs to, and carries no icon of its + // own -- that button's Icons.list already names what is being counted. + StreamBuilder( + stream: repository.watchCount(), + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Center(child: Text('$count')), + ); + }, + ), IconButton( tooltip: 'Settings', onPressed: onOpenSettings, diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 3ea4a55..58b75e2 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -13,11 +13,10 @@ import 'package:todo/sync/local_backup.dart'; import 'package:todo/sync/sync_settings.dart'; import 'package:todo/ui/capture_app_bar.dart'; import 'package:todo/ui/capture_draft.dart'; -import 'package:todo/ui/capture_meta_row.dart'; import 'package:todo/ui/capture_status.dart'; import 'package:todo/ui/capture_status_footer.dart'; import 'package:todo/ui/capture_sync_runner.dart'; -import 'package:todo/ui/note_editor.dart'; +import 'package:todo/ui/note_form.dart'; import 'package:todo/ui/notes_list_screen.dart'; import 'package:todo/ui/settings_screen.dart'; @@ -75,10 +74,6 @@ class _CaptureScreenState extends State /// hammer GitHub with a request per focus change. Timer? _autoSyncDebounce; - /// Hides the Priority/Status row while the editor's own bare-guided chrome - /// (template/mode selectors) is also hidden, so the two stay in lockstep. - bool _chromeVisible = true; - /// When this session started, for the duration on the `app_close` event. final DateTime _sessionStart = DateTime.now(); @@ -232,12 +227,6 @@ class _CaptureScreenState extends State } } - /// Mirrors the editor's chrome visibility so this screen's own - /// priority/status row hides and shows in step with it. - void _setChromeVisible(bool visible) { - setState(() => _chromeVisible = visible); - } - /// setState, reachable from this screen's `part` files. /// /// setState is protected to State subclasses, so an extension in a part diff --git a/lib/ui/capture_screen_sync.dart b/lib/ui/capture_screen_sync.dart index a7377da..e369ad7 100644 --- a/lib/ui/capture_screen_sync.dart +++ b/lib/ui/capture_screen_sync.dart @@ -54,10 +54,11 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { void _openList() { _logEvent('screen_view', params: {'screen': 'notes_list'}); - unawaited( - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NotesListScreen(repository: widget.repository), + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotesListScreen( + repository: widget.repository, + appSettings: widget.appSettings, ), ), ); @@ -93,36 +94,22 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Hidden together with the editor's own chrome while the - // bare guided stepper or its entry wizard is up, so the top - // of the screen stays free of noise. Also hidden outright - // when advanced mode is off. - if (advanced && _chromeVisible) ...[ - CaptureMetaRow( + Expanded( + // Bumping this key is how "new note" gets a fresh editor: it + // remounts NoteForm, which also resets the chrome gate to + // visible. Keep it here rather than inside NoteForm — a key + // below the conditional metadata row can't be matched after + // advancedMode toggles that row in or out, so Flutter tears + // the editor down and remounts it with an empty draft, + // discarding whatever the user was typing (fixed in ce2ea5b). + key: ValueKey(_editorGeneration), + child: NoteForm( + advancedMode: advanced, + initialTemplate: NoteTemplate.defaultTemplate, priority: _draft.priority, status: _draft.status, onPriorityChanged: _setPriority, onStatusChanged: _setStatus, - ), - const SizedBox(height: 12), - ], - Expanded( - // The key lives on this Expanded, not on NoteEditor itself: - // the Row above is conditionally present, which shifts - // this Expanded's position in the Column whenever - // advancedMode changes at runtime. A key one level too - // deep (previously on NoteEditor) doesn't survive that - // reposition — Flutter can't match it at the new index, - // so it tears down and remounts NoteEditor with an empty - // draft, discarding whatever the user was typing. - key: ValueKey(_editorGeneration), - child: NoteEditor( - initialTemplate: NoteTemplate.defaultTemplate, - initialMode: NoteEditorMode.raw, - advancedMode: advanced, - priority: _draft.priority, - onPriorityChanged: _setPriority, - onChromeVisibleChanged: _setChromeVisible, autofocus: true, onChanged: _onChanged, ), @@ -172,9 +159,10 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { void _saveAndReset() { _logEvent('new_note_reset'); _rebuild(() { + // Bumping the generation remounts NoteForm, which resets the chrome + // gate to visible for free -- no separate flag to keep in step. _editorGeneration++; // recreate the editor with a fresh template _draft.reset(); - _chromeVisible = true; }); } diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index 77fa417..4ae992c 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_template.dart'; import 'package:todo/ui/confirm.dart'; -import 'package:todo/ui/note_editor.dart'; +import 'package:todo/ui/note_form.dart'; /// Full-screen view of a single note: read it in full, edit its body through -/// the guided [NoteEditor], change its priority/status, or delete it. +/// the shared [NoteForm], change its priority/status, or delete it. /// /// Edits persist immediately (matching the capture screen's autosave), so /// there is no explicit save button. The template is detected from the note's @@ -17,6 +18,7 @@ class NoteDetailScreen extends StatefulWidget { const NoteDetailScreen({ required this.note, required this.repository, + required this.appSettings, super.key, }); @@ -26,6 +28,11 @@ class NoteDetailScreen extends StatefulWidget { /// The store edits persist to. final NoteRepository repository; + /// Drives whether the metadata row and editor chrome are shown. Required, + /// not defaulted: this screen silently defaulting it to "advanced" is + /// exactly what made editing look different from capture. + final ValueNotifier appSettings; + @override State createState() => _NoteDetailScreenState(); } @@ -33,10 +40,6 @@ class NoteDetailScreen extends StatefulWidget { class _NoteDetailScreenState extends State { late Note _note = widget.note; - /// Hides the Priority/Status row while the editor's own bare-guided chrome - /// (template/mode selectors) is also hidden, so the two stay in lockstep. - bool _chromeVisible = true; - Future _persist(Note next) async { setState(() => _note = next); await widget.repository.upsert(next); @@ -72,92 +75,24 @@ class _NoteDetailScreenState extends State { ), body: Padding( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_chromeVisible) ...[ - Row( - children: [ - Expanded( - child: _MetaDropdown( - label: 'Priority', - value: _note.priority, - values: Priority.values, - labelOf: (p) => p.label, - onChanged: (p) => _persist( - _note.copyWith(priority: p, updatedAt: DateTime.now()), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetaDropdown( - label: 'Status', - value: _note.status, - values: Status.values, - labelOf: (s) => s.label, - onChanged: (s) => _persist( - _note.copyWith(status: s, updatedAt: DateTime.now()), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ], - Expanded( - child: NoteEditor( - initialText: _note.text, - initialMode: NoteEditorMode.raw, - priority: _note.priority, - onPriorityChanged: (p) => _persist( - _note.copyWith(priority: p, updatedAt: DateTime.now()), - ), - onChromeVisibleChanged: (visible) => - setState(() => _chromeVisible = visible), - onChanged: _onTextChanged, - ), + // The same form capture renders, gated on the same setting, so an + // edited note and a fresh one cannot look different. + child: ValueListenableBuilder( + valueListenable: widget.appSettings, + builder: (context, settings, _) => NoteForm( + advancedMode: settings.advancedMode, + initialText: _note.text, + priority: _note.priority, + status: _note.status, + onPriorityChanged: (p) => _persist( + _note.copyWith(priority: p, updatedAt: DateTime.now()), ), - ], + onStatusChanged: (s) => + _persist(_note.copyWith(status: s, updatedAt: DateTime.now())), + onChanged: _onTextChanged, + ), ), ), ); } } - -/// A compact labelled dropdown for picking an enum value (priority/status). -class _MetaDropdown extends StatelessWidget { - const _MetaDropdown({ - required this.label, - required this.value, - required this.values, - required this.labelOf, - required this.onChanged, - }); - - final String label; - final T value; - final List values; - final String Function(T) labelOf; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return DropdownButtonFormField( - initialValue: value, - decoration: InputDecoration( - labelText: label, - isDense: true, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - items: [ - for (final v in values) - DropdownMenuItem(value: v, child: Text(labelOf(v))), - ], - onChanged: (v) { - if (v != null) onChanged(v); - }, - ); - } -} diff --git a/lib/ui/note_form.dart b/lib/ui/note_form.dart new file mode 100644 index 0000000..193456c --- /dev/null +++ b/lib/ui/note_form.dart @@ -0,0 +1,99 @@ +/// Composes the note-editing form shared by capture and the detail screen. +/// +/// Composition only, no logic of its own: [NoteFormChromeGate] decides +/// visibility, [NoteMetaRow] renders the priority/status row, and [NoteEditor] +/// owns the text. Both surfaces render this so an edited note and a fresh +/// capture cannot look different -- the divergence this replaced was the +/// detail screen defaulting `advancedMode` to true while the app-wide setting +/// defaults to false. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_template.dart'; +import 'package:todo/ui/note_editor.dart'; +import 'package:todo/ui/note_form_chrome_gate.dart'; +import 'package:todo/ui/note_meta_row.dart'; + +/// The metadata row above a [NoteEditor], gated on advanced mode. +class NoteForm extends StatelessWidget { + /// Creates the form for the given note values. + const NoteForm({ + required this.advancedMode, + required this.priority, + required this.status, + required this.onPriorityChanged, + required this.onStatusChanged, + required this.onChanged, + this.initialText = '', + this.initialTemplate, + this.autofocus = false, + super.key, + }); + + /// Whether the metadata row and the editor's own chrome may be shown. + final bool advancedMode; + + /// The note's current priority. + final Priority priority; + + /// The note's current status. + final Status status; + + /// Called with a newly picked priority. + final ValueChanged onPriorityChanged; + + /// Called with a newly picked status. + final ValueChanged onStatusChanged; + + /// Called with the freshly assembled note text on every edit. + final ValueChanged onChanged; + + /// Existing note text to load. Empty for a fresh draft. + final String initialText; + + /// Template to author with. Null detects it from [initialText], which is + /// what opening an existing note wants. + final NoteTemplate? initialTemplate; + + /// Autofocus the editor, so a fresh capture needs zero taps before typing. + final bool autofocus; + + @override + Widget build(BuildContext context) { + return NoteFormChromeGate( + advancedMode: advancedMode, + builder: + (context, {required chromeVisible, required onChromeVisibleChanged}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (chromeVisible) ...[ + NoteMetaRow( + priority: priority, + status: status, + onPriorityChanged: onPriorityChanged, + onStatusChanged: onStatusChanged, + ), + const SizedBox(height: 12), + ], + Expanded( + child: NoteEditor( + initialText: initialText, + initialTemplate: initialTemplate, + initialMode: NoteEditorMode.raw, + advancedMode: advancedMode, + priority: priority, + onPriorityChanged: onPriorityChanged, + onChromeVisibleChanged: onChromeVisibleChanged, + autofocus: autofocus, + onChanged: onChanged, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/ui/note_form_chrome_gate.dart b/lib/ui/note_form_chrome_gate.dart new file mode 100644 index 0000000..7fdaea5 --- /dev/null +++ b/lib/ui/note_form_chrome_gate.dart @@ -0,0 +1,58 @@ +/// Decides whether a note form's chrome (the priority/status row) is visible. +/// +/// This is the one piece of state capture and the detail screen used to keep +/// separately, in copy-pasted form -- which is how the two drifted apart. It +/// lives here so both surfaces resolve visibility by the same rule. +library; + +import 'package:flutter/material.dart'; + +/// Holds the editor's chrome-visibility signal and resolves it against +/// advanced mode. +/// +/// Decides visibility and nothing else: it neither builds the metadata row nor +/// the editor. [builder] receives the resolved answer plus the callback to +/// hand to `NoteEditor.onChromeVisibleChanged`. +class NoteFormChromeGate extends StatefulWidget { + /// Creates a gate resolving visibility against [advancedMode]. + const NoteFormChromeGate({ + required this.advancedMode, + required this.builder, + super.key, + }); + + /// When false the chrome is never shown, whatever the editor reports -- + /// casual capture needs neither the metadata row nor the mode toggle. + final bool advancedMode; + + /// Built with the resolved visibility and the editor's visibility callback. + final Widget Function( + BuildContext context, { + required bool chromeVisible, + required ValueChanged onChromeVisibleChanged, + }) + builder; + + @override + State createState() => _NoteFormChromeGateState(); +} + +class _NoteFormChromeGateState extends State { + /// What the editor last reported. Starts true because a freshly mounted + /// editor shows its chrome until Guided (wizard or bare stepper) takes over. + bool _editorChromeVisible = true; + + void _setEditorChromeVisible(bool visible) { + if (visible == _editorChromeVisible) return; + setState(() => _editorChromeVisible = visible); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + chromeVisible: widget.advancedMode && _editorChromeVisible, + onChromeVisibleChanged: _setEditorChromeVisible, + ); + } +} diff --git a/lib/ui/capture_meta_row.dart b/lib/ui/note_meta_row.dart similarity index 79% rename from lib/ui/capture_meta_row.dart rename to lib/ui/note_meta_row.dart index 9df6f50..94ac395 100644 --- a/lib/ui/capture_meta_row.dart +++ b/lib/ui/note_meta_row.dart @@ -1,8 +1,8 @@ -/// The capture screen's metadata controls: the priority/status row and the -/// generic dropdown behind it. +/// A note's metadata controls: the priority/status row and the generic +/// dropdown behind it. /// -/// Split out of `capture_status.dart` for file size. Public only because Dart -/// privacy is library-scoped. +/// Shared by both editing surfaces -- capture and the detail screen -- so the +/// two cannot drift apart. Public only because Dart privacy is library-scoped. library; import 'package:flutter/material.dart'; @@ -66,14 +66,13 @@ class MetaDropdown extends StatelessWidget { } } -/// The draft's priority and status pickers, side by side. +/// A note's priority and status pickers, side by side. /// -/// Hidden together with the editor's own chrome while the bare guided -/// stepper or its entry wizard is up, so the top of the screen stays free of -/// noise -- the screen decides that; this only lays the row out. -class CaptureMetaRow extends StatelessWidget { - /// Creates the metadata row for the current draft values. - const CaptureMetaRow({ +/// Lays the row out and nothing else: whether it is visible at all is +/// [NoteFormChromeGate]'s job, not this widget's. +class NoteMetaRow extends StatelessWidget { + /// Creates the metadata row for the current values. + const NoteMetaRow({ required this.priority, required this.status, required this.onPriorityChanged, @@ -81,10 +80,10 @@ class CaptureMetaRow extends StatelessWidget { super.key, }); - /// The draft's current priority. + /// The note's current priority. final Priority priority; - /// The draft's current status. + /// The note's current status. final Status status; /// Called with a newly picked priority. diff --git a/lib/ui/notes_list_navigation.dart b/lib/ui/notes_list_navigation.dart index 8bb16d2..c55dc3a 100644 --- a/lib/ui/notes_list_navigation.dart +++ b/lib/ui/notes_list_navigation.dart @@ -7,6 +7,7 @@ library; import 'package:flutter/material.dart'; +import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/ui/note_actions_sheet.dart'; @@ -17,10 +18,15 @@ Future openNote( BuildContext context, Note note, NoteRepository repository, + ValueNotifier appSettings, ) async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => NoteDetailScreen(note: note, repository: repository), + builder: (_) => NoteDetailScreen( + note: note, + repository: repository, + appSettings: appSettings, + ), ), ); } diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index b7b1c0e..81d4221 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/ui/note_tile.dart'; @@ -21,11 +22,19 @@ const Set kDefaultStatuses = {Status.todo, Status.inProgress}; /// rebuild (e.g. a search keystroke) does not churn a new DB subscription. class NotesListScreen extends StatefulWidget { /// Creates a [NotesListScreen] backed by [repository]. - const NotesListScreen({required this.repository, super.key}); + const NotesListScreen({ + required this.repository, + required this.appSettings, + super.key, + }); /// The store this screen searches/filters/sorts. final NoteRepository repository; + /// Passed through to the detail screen so an opened note is edited with the + /// same chrome capture uses. + final ValueNotifier appSettings; + @override State createState() => _NotesListScreenState(); } @@ -207,8 +216,12 @@ class _NotesListScreenState extends State { itemBuilder: (context, i) => NoteTile( key: ValueKey(notes[i].id), note: notes[i], - onTap: () => - openNote(context, notes[i], widget.repository), + onTap: () => openNote( + context, + notes[i], + widget.repository, + widget.appSettings, + ), onActions: () => openNoteActions(context, notes[i], widget.repository), ), diff --git a/pubspec.yaml b/pubspec.yaml index 9e9ce70..8f37ecf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.21+1 +version: 1.0.22+1 environment: sdk: ^3.12.2 diff --git a/test/capture_screen_sync_test.dart b/test/capture_screen_sync_test.dart index 3e03231..864661e 100644 --- a/test/capture_screen_sync_test.dart +++ b/test/capture_screen_sync_test.dart @@ -175,10 +175,10 @@ void main() { ); await tester.pump(); - // The Row above the editor is conditionally present, which shifts the - // editor's position in the Column whenever advancedMode changes at - // runtime — a key one level too shallow doesn't survive that - // reposition and Flutter remounts the editor with an empty draft. + // NoteForm's metadata row is conditionally present, so toggling + // advancedMode changes the editor's position beneath it — a key one + // level too shallow doesn't survive that reposition and Flutter + // remounts the editor with an empty draft, losing the typing. appSettings.value = const AppSettings(advancedMode: false); await tester.pump(); diff --git a/test/note_detail_screen_test.dart b/test/note_detail_screen_test.dart index ee2c0d2..de5dd75 100644 --- a/test/note_detail_screen_test.dart +++ b/test/note_detail_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/ui/note_detail_screen.dart'; @@ -15,7 +16,11 @@ void main() { updatedAt: DateTime(2026, 6, 15, 9), ); - Future pumpDetail(WidgetTester tester, Note note) async { + Future pumpDetail( + WidgetTester tester, + Note note, { + bool advancedMode = true, + }) async { tester.view.physicalSize = const Size(1200, 2400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -23,9 +28,15 @@ void main() { final repo = FakeNoteRepository([note]); addTearDown(repo.close); + final settings = ValueNotifier(AppSettings(advancedMode: advancedMode)); + addTearDown(settings.dispose); await tester.pumpWidget( MaterialApp( - home: NoteDetailScreen(note: note, repository: repo), + home: NoteDetailScreen( + note: note, + repository: repo, + appSettings: settings, + ), ), ); await tester.pump(); @@ -46,7 +57,7 @@ void main() { final repo = await pumpDetail(tester, seedNote('# T')); await tester.tap( - find.byWidgetPredicate((w) => w is DropdownButtonFormField), + find.byWidgetPredicate((w) => w is DropdownButton), ); await tester.pumpAndSettle(); await tester.tap(find.text('High').last); @@ -59,7 +70,7 @@ void main() { final repo = await pumpDetail(tester, seedNote('# T')); await tester.tap( - find.byWidgetPredicate((w) => w is DropdownButtonFormField), + find.byWidgetPredicate((w) => w is DropdownButton), ); await tester.pumpAndSettle(); await tester.tap(find.text('Done').last); @@ -109,6 +120,28 @@ void main() { }, ); + // The regression this screen was built to stop: it used to let + // NoteEditor.advancedMode default to true, so editing an existing note + // showed the template dropdown and the View/Guided/Raw pills even though + // capture -- with the same app-wide setting off -- showed a bare box. + testWidgets('advanced mode off hides every chrome control, as capture does', ( + tester, + ) async { + await pumpDetail( + tester, + seedNote('# T\n\n## what\nbody'), + advancedMode: false, + ); + + expect(find.text('Template'), findsNothing); + expect(find.text('Guided'), findsNothing); + expect(find.text('Raw'), findsNothing); + expect(find.text('Priority'), findsNothing); + expect(find.text('Status'), findsNothing); + // The body itself is still editable — only the chrome is gone. + expect(find.byType(TextField), findsOneWidget); + }); + testWidgets('the delete action removes the note and pops', (tester) async { final repo = await pumpDetail(tester, seedNote('# Bye')); diff --git a/test/notes_list_harness.dart b/test/notes_list_harness.dart index de4745b..dd63389 100644 --- a/test/notes_list_harness.dart +++ b/test/notes_list_harness.dart @@ -7,8 +7,8 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; -import 'package:todo/data/note_repository.dart'; import 'package:todo/ui/notes_list_screen.dart'; import 'fake_note_repository.dart'; @@ -33,11 +33,16 @@ Note note( Future pumpList( WidgetTester tester, { List seed = const [], + bool advancedMode = true, }) async { final repo = FakeNoteRepository(seed); addTearDown(repo.close); + final settings = ValueNotifier(AppSettings(advancedMode: advancedMode)); + addTearDown(settings.dispose); await tester.pumpWidget( - MaterialApp(home: NotesListScreen(repository: repo)), + MaterialApp( + home: NotesListScreen(repository: repo, appSettings: settings), + ), ); await tester.pump(); // flush the initial stream emit return repo;