todo-app/lib/ui/note_detail_screen.dart
Krzysztof Rudnicki e103261bc8 Confirm before deleting a note
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>
2026-07-30 16:31:15 +02:00

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);
},
);
}
}