mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
4e66de5467
commit
85a45daaee
@ -68,30 +68,10 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
),
|
||||
),
|
||||
// coverage:ignore-end
|
||||
// Live count of stored notes, proving local persistence.
|
||||
StreamBuilder<int>(
|
||||
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<int>(
|
||||
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,
|
||||
|
||||
@ -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<CaptureScreen>
|
||||
/// 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<CaptureScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@ -54,10 +54,11 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
|
||||
void _openList() {
|
||||
_logEvent('screen_view', params: {'screen': 'notes_list'});
|
||||
unawaited(
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotesListScreen(repository: widget.repository),
|
||||
Navigator.of(context).push<void>(
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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> appSettings;
|
||||
|
||||
@override
|
||||
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
|
||||
}
|
||||
@ -33,10 +40,6 @@ class NoteDetailScreen extends StatefulWidget {
|
||||
class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
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<void> _persist(Note next) async {
|
||||
setState(() => _note = next);
|
||||
await widget.repository.upsert(next);
|
||||
@ -72,92 +75,24 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_chromeVisible) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaDropdown<Priority>(
|
||||
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<Status>(
|
||||
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<AppSettings>(
|
||||
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<T> 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<T> values;
|
||||
final String Function(T) labelOf;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<T>(
|
||||
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<T>(value: v, child: Text(labelOf(v))),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
99
lib/ui/note_form.dart
Normal file
99
lib/ui/note_form.dart
Normal file
@ -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<Priority> onPriorityChanged;
|
||||
|
||||
/// Called with a newly picked status.
|
||||
final ValueChanged<Status> onStatusChanged;
|
||||
|
||||
/// Called with the freshly assembled note text on every edit.
|
||||
final ValueChanged<String> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/ui/note_form_chrome_gate.dart
Normal file
58
lib/ui/note_form_chrome_gate.dart
Normal file
@ -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<bool> onChromeVisibleChanged,
|
||||
})
|
||||
builder;
|
||||
|
||||
@override
|
||||
State<NoteFormChromeGate> createState() => _NoteFormChromeGateState();
|
||||
}
|
||||
|
||||
class _NoteFormChromeGateState extends State<NoteFormChromeGate> {
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<T> 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.
|
||||
@ -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<void> openNote(
|
||||
BuildContext context,
|
||||
Note note,
|
||||
NoteRepository repository,
|
||||
ValueNotifier<AppSettings> appSettings,
|
||||
) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NoteDetailScreen(note: note, repository: repository),
|
||||
builder: (_) => NoteDetailScreen(
|
||||
note: note,
|
||||
repository: repository,
|
||||
appSettings: appSettings,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Status> 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> appSettings;
|
||||
|
||||
@override
|
||||
State<NotesListScreen> createState() => _NotesListScreenState();
|
||||
}
|
||||
@ -207,8 +216,12 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
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),
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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<FakeNoteRepository> pumpDetail(WidgetTester tester, Note note) async {
|
||||
Future<FakeNoteRepository> 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<Priority>),
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
||||
);
|
||||
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<Status>),
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
||||
);
|
||||
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'));
|
||||
|
||||
|
||||
@ -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<FakeNoteRepository> pumpList(
|
||||
WidgetTester tester, {
|
||||
List<Note> 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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user