mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:07:48 +01:00
Both delete paths -- the per-note actions sheet and the detail screen -- deleted immediately, with no confirmation and no undo. With a pointer that is merely risky: you have to travel to the control and press it. With a keyboard it is one Tab away from a Return you were about to press anyway, and the note is gone with nothing to recover it. The confirmation is built so the SAFE option is what a reflexive keypress hits: Cancel is autofocused, Escape dismisses as "no", and the destructive button is error-coloured but deliberately not the default action. Escape is bound explicitly rather than relying on the barrier, because the desktop surface here is a Chrome --app window where that cannot be assumed -- and a confirmation the keyboard cannot back out of is worse than no confirmation at all. The actions sheet also gains isScrollControlled. Default bottom sheets cap at 9/16 of the viewport, i.e. ~432px on a 768px screen, and this sheet's height grows with the note title and its chip rows -- so Delete could be clipped out of reach on the very screen this app is used on. The two existing delete tests correctly failed once confirmation landed; both now go through the dialog, and each gains a cancel-keeps-the-note counterpart so the negative path is covered too. 204 tests, 100% line coverage held. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
5.0 KiB
Dart
164 lines
5.0 KiB
Dart
import 'package:flutter/material.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';
|
|
|
|
/// 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.
|
|
///
|
|
/// Edits persist immediately (matching the capture screen's autosave), so
|
|
/// there is no explicit save button. The template is detected from the note's
|
|
/// text, falling back to a raw editor for freeform/legacy notes.
|
|
class NoteDetailScreen extends StatefulWidget {
|
|
/// Creates a [NoteDetailScreen] editing [note].
|
|
const NoteDetailScreen({
|
|
required this.note,
|
|
required this.repository,
|
|
super.key,
|
|
});
|
|
|
|
/// The note being viewed/edited.
|
|
final Note note;
|
|
|
|
/// The store edits persist to.
|
|
final NoteRepository repository;
|
|
|
|
@override
|
|
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
Future<void> _onTextChanged(String text) =>
|
|
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
|
|
|
|
Future<void> _delete() async {
|
|
final confirmed = await confirmDestructive(
|
|
context,
|
|
title: 'Delete note?',
|
|
message: 'This cannot be undone.',
|
|
);
|
|
if (!confirmed) return;
|
|
await widget.repository.delete(_note.id);
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final title = noteTitle(_note.text);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(title.isEmpty ? '(empty)' : title),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Delete note',
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: _delete,
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
},
|
|
);
|
|
}
|
|
}
|