diff --git a/docs/progress.md b/docs/progress.md index 33f3227..39bd446 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -7,6 +7,39 @@ Updated: 2026-02-22 - After every sensible code/documentation change set, create a git commit as the last step so the next agent session can pick up from clean checkpoints. +## Latest Milestone (2026-02-22): Quick-Capture Dialog Controller Lifecycle Fix + +Fixed a runtime crash when creating captures/notes from profile-context quick +actions (`TextEditingController was used after being disposed`) caused by a +dialog animation/disposal race. + +- Root cause + - quick-capture dialogs in `People` and graph `Person Insights` created a + local `TextEditingController`, passed it into `showDialog(...)`, then + disposed it immediately after `await showDialog(...)` + - the dialog route can still rebuild during dismiss animation, which caused + the `TextField` to touch a disposed controller + +- Fixes + - `lib/features/people/people_view.dart` + - replaced ad-hoc quick text capture dialog with stateful + `_QuickTextCaptureDialog` that owns and disposes its controller internally + - `lib/features/dashboard/dashboard_view.dart` + - replaced ad-hoc quick text capture dialog with stateful + `_InsightQuickTextCaptureDialog` with the same lifecycle fix + - both dialogs now also show inline `Required` validation instead of silent + no-op when pressing save with empty text + +- Similar issue scan + - searched the codebase for the same anti-pattern (local + `TextEditingController` created outside dialog widget and disposed after + `showDialog`) + - no remaining occurrences found after patching these two flows + +- Validation + - `flutter analyze` -> pass + - `flutter test` -> pass + ## Latest Milestone (2026-02-22): Preference Review UI (People + Graph Insights) Exposed the new chat-derived preference signals in the main person detail diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 7c0130e..1fc26e6 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -1323,39 +1323,12 @@ class _PersonInsightsPage extends ConsumerWidget { required String title, required String hintText, }) async { - final TextEditingController controller = TextEditingController(); - final String? result = await showDialog( + return showDialog( context: context, builder: (BuildContext context) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - minLines: 2, - maxLines: 4, - decoration: InputDecoration(hintText: hintText), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String value = controller.text.trim(); - if (value.isEmpty) { - return; - } - Navigator.of(context).pop(value); - }, - child: const Text('Save'), - ), - ], - ); + return _InsightQuickTextCaptureDialog(title: title, hintText: hintText); }, ); - controller.dispose(); - return result; } Widget _insightAvatar({ @@ -1881,6 +1854,80 @@ class _InsightQuickReminderDialogState } } +class _InsightQuickTextCaptureDialog extends StatefulWidget { + const _InsightQuickTextCaptureDialog({ + required this.title, + required this.hintText, + }); + + final String title; + final String hintText; + + @override + State<_InsightQuickTextCaptureDialog> createState() => + _InsightQuickTextCaptureDialogState(); +} + +class _InsightQuickTextCaptureDialogState + extends State<_InsightQuickTextCaptureDialog> { + late final TextEditingController _controller; + bool _attemptedSubmit = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String value = _controller.text.trim(); + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + minLines: 2, + maxLines: 4, + autofocus: true, + onChanged: (_) { + if (_attemptedSubmit) { + setState(() {}); + } + }, + decoration: InputDecoration( + hintText: widget.hintText, + errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String submitted = _controller.text.trim(); + if (submitted.isEmpty) { + setState(() { + _attemptedSubmit = true; + }); + return; + } + Navigator.of(context).pop(submitted); + }, + child: const Text('Save'), + ), + ], + ); + } +} + class _InsightSectionCard extends StatelessWidget { const _InsightSectionCard({ required this.title, diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 83b9889..4f0905c 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -672,39 +672,12 @@ class PeopleView extends ConsumerWidget { required String title, required String hintText, }) async { - final TextEditingController controller = TextEditingController(); - final String? result = await showDialog( + return showDialog( context: context, builder: (BuildContext context) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - minLines: 2, - maxLines: 4, - decoration: InputDecoration(hintText: hintText), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String value = controller.text.trim(); - if (value.isEmpty) { - return; - } - Navigator.of(context).pop(value); - }, - child: const Text('Save'), - ), - ], - ); + return _QuickTextCaptureDialog(title: title, hintText: hintText); }, ); - controller.dispose(); - return result; } } @@ -2046,6 +2019,76 @@ class _PersonEditorDialog extends StatelessWidget { } } +class _QuickTextCaptureDialog extends StatefulWidget { + const _QuickTextCaptureDialog({required this.title, required this.hintText}); + + final String title; + final String hintText; + + @override + State<_QuickTextCaptureDialog> createState() => + _QuickTextCaptureDialogState(); +} + +class _QuickTextCaptureDialogState extends State<_QuickTextCaptureDialog> { + late final TextEditingController _controller; + bool _attemptedSubmit = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final String value = _controller.text.trim(); + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + minLines: 2, + maxLines: 4, + autofocus: true, + onChanged: (_) { + if (_attemptedSubmit) { + setState(() {}); + } + }, + decoration: InputDecoration( + hintText: widget.hintText, + errorText: _attemptedSubmit && value.isEmpty ? 'Required' : null, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String submitted = _controller.text.trim(); + if (submitted.isEmpty) { + setState(() { + _attemptedSubmit = true; + }); + return; + } + Navigator.of(context).pop(submitted); + }, + child: const Text('Save'), + ), + ], + ); + } +} + class _PersonEditorBottomSheet extends StatelessWidget { const _PersonEditorBottomSheet({ required this.title,