Fix quick-capture dialog controller disposal race

This commit is contained in:
Rijad Zuzo
2026-02-22 23:53:22 +01:00
parent 1f31a0e769
commit 05588cc58e
3 changed files with 181 additions and 58 deletions
+33
View File
@@ -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
+76 -29
View File
@@ -1323,39 +1323,12 @@ class _PersonInsightsPage extends ConsumerWidget {
required String title,
required String hintText,
}) async {
final TextEditingController controller = TextEditingController();
final String? result = await showDialog<String>(
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(hintText: hintText),
),
actions: <Widget>[
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: <Widget>[
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,
+72 -29
View File
@@ -672,39 +672,12 @@ class PeopleView extends ConsumerWidget {
required String title,
required String hintText,
}) async {
final TextEditingController controller = TextEditingController();
final String? result = await showDialog<String>(
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(hintText: hintText),
),
actions: <Widget>[
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: <Widget>[
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,