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 - 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. 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) ## Latest Milestone (2026-02-22): Preference Review UI (People + Graph Insights)
Exposed the new chat-derived preference signals in the main person detail 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 title,
required String hintText, required String hintText,
}) async { }) async {
final TextEditingController controller = TextEditingController(); return showDialog<String>(
final String? result = await showDialog<String>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return _InsightQuickTextCaptureDialog(title: title, hintText: hintText);
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'),
),
],
);
}, },
); );
controller.dispose();
return result;
} }
Widget _insightAvatar({ 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 { class _InsightSectionCard extends StatelessWidget {
const _InsightSectionCard({ const _InsightSectionCard({
required this.title, required this.title,
+72 -29
View File
@@ -672,39 +672,12 @@ class PeopleView extends ConsumerWidget {
required String title, required String title,
required String hintText, required String hintText,
}) async { }) async {
final TextEditingController controller = TextEditingController(); return showDialog<String>(
final String? result = await showDialog<String>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return _QuickTextCaptureDialog(title: title, hintText: hintText);
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'),
),
],
);
}, },
); );
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 { class _PersonEditorBottomSheet extends StatelessWidget {
const _PersonEditorBottomSheet({ const _PersonEditorBottomSheet({
required this.title, required this.title,