diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 06979a0d..2506ead1 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -27,6 +27,7 @@ import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; +import '../../utils/subject_suggestions.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; @@ -3382,106 +3383,168 @@ class _TaskDetailScreenState extends ConsumerState WidgetRef ref, Task task, ) async { + final dialogShape = AppSurfaces.of(context).dialogShape; // offices will be watched inside the dialog's Consumer so the dialog // can rebuild independently when the provider completes. final titleCtrl = TextEditingController(text: task.title); final descCtrl = TextEditingController(text: task.description); + final existingSubjects = [ + ...((ref.read(tasksProvider).valueOrNull ?? const []).map( + (task) => task.title, + )), + ...((ref.read(ticketsProvider).valueOrNull ?? const []).map( + (ticket) => ticket.subject, + )), + ]; String? selectedOffice = task.officeId; await showDialog( context: context, builder: (dialogContext) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: const Text('Edit Task'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: titleCtrl, - decoration: const InputDecoration(labelText: 'Title'), - ), - const SizedBox(height: 8), - TextField( - controller: descCtrl, - decoration: const InputDecoration(labelText: 'Description'), - maxLines: 4, - ), - const SizedBox(height: 8), - Consumer( - builder: (dialogContext, dialogRef, _) { - final officesAsync = dialogRef.watch(officesOnceProvider); - return officesAsync.when( - data: (offices) { - final officesSorted = List.from(offices) - ..sort( - (a, b) => a.name.toLowerCase().compareTo( - b.name.toLowerCase(), - ), - ); - return DropdownButtonFormField( - initialValue: selectedOffice, - decoration: const InputDecoration( - labelText: 'Office', - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - for (final o in officesSorted) - DropdownMenuItem( - value: o.id, - child: Text(o.name), - ), - ], - onChanged: (v) => selectedOffice = v, + var saving = false; + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + shape: dialogShape, + title: const Text('Edit Task'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: titleCtrl, + enabled: !saving, + decoration: const InputDecoration(labelText: 'Title'), + ), + suggestionsCallback: (pattern) async { + return SubjectSuggestionEngine.suggest( + existingSubjects: existingSubjects, + query: pattern, + limit: 8, ); }, - // Show a linear progress indicator while offices are being - // retrieved so the user sees that the dialog is loading - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: LinearProgressIndicator(), + itemBuilder: (context, suggestion) => + ListTile(dense: true, title: Text(suggestion)), + onSuggestionSelected: (suggestion) { + titleCtrl + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + }, + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Description', ), - error: (error, _) => const SizedBox.shrink(), - ); - }, + maxLines: 4, + ), + const SizedBox(height: 8), + Consumer( + builder: (dialogContext, dialogRef, _) { + final officesAsync = dialogRef.watch( + officesOnceProvider, + ); + return officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return DropdownButtonFormField( + initialValue: selectedOffice, + decoration: const InputDecoration( + labelText: 'Office', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + for (final o in officesSorted) + DropdownMenuItem( + value: o.id, + child: Text(o.name), + ), + ], + onChanged: saving + ? null + : (v) => setDialogState( + () => selectedOffice = v, + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: LinearProgressIndicator(), + ), + error: (error, _) => const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: saving + ? null + : () async { + final title = + SubjectSuggestionEngine.normalizeDisplay( + titleCtrl.text.trim(), + ); + final desc = descCtrl.text.trim(); + setDialogState(() => saving = true); + try { + await ref + .read(tasksControllerProvider) + .updateTaskFields( + taskId: task.id, + title: title.isEmpty ? null : title, + description: desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + ref.invalidate(tasksProvider); + ref.invalidate(taskByIdProvider(task.id)); + if (!mounted) return; + Navigator.of(dialogContext).pop(); + showSuccessSnackBar(context, 'Task updated'); + } catch (e) { + if (!mounted) return; + showErrorSnackBar( + context, + 'Failed to update task: $e', + ); + } finally { + if (dialogContext.mounted) { + setDialogState(() => saving = false); + } + } + }, + child: saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), ), ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - final outerContext = context; - final title = titleCtrl.text.trim(); - final desc = descCtrl.text.trim(); - try { - await ref - .read(tasksControllerProvider) - .updateTaskFields( - taskId: task.id, - title: title.isEmpty ? null : title, - description: desc.isEmpty ? null : desc, - officeId: selectedOffice, - ); - if (!mounted) return; - Navigator.of(outerContext).pop(); - showSuccessSnackBar(outerContext, 'Task updated'); - } catch (e) { - if (!mounted) return; - showErrorSnackBar(outerContext, 'Failed to update task: $e'); - } - }, - child: const Text('Save'), - ), - ], + ); + }, ); }, );