Fix quick-capture dialog controller disposal race
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user