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
|
- 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user