diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 7f701937..5c3f0ecf 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; @@ -23,6 +24,7 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; +import '../../utils/subject_suggestions.dart'; // request metadata options used in task creation/editing dialogs const List _requestTypeOptions = [ @@ -565,6 +567,14 @@ class _TasksListScreenState extends ConsumerState ) async { final titleController = TextEditingController(); final descriptionController = TextEditingController(); + final existingSubjects = [ + ...((ref.read(tasksProvider).valueOrNull ?? const []).map( + (task) => task.title, + )), + ...((ref.read(ticketsProvider).valueOrNull ?? const []).map( + (ticket) => ticket.subject, + )), + ]; String? selectedOfficeId; String? selectedRequestType; String? requestTypeOther; @@ -585,12 +595,30 @@ class _TasksListScreenState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: titleController, - decoration: const InputDecoration( - labelText: 'Task title', + TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Task title', + ), + enabled: !saving, ), - enabled: !saving, + suggestionsCallback: (pattern) async { + return SubjectSuggestionEngine.suggest( + existingSubjects: existingSubjects, + query: pattern, + limit: 8, + ); + }, + itemBuilder: (context, suggestion) => + ListTile(dense: true, title: Text(suggestion)), + onSuggestionSelected: (suggestion) { + titleController + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + }, ), const SizedBox(height: 12), TextField( @@ -710,7 +738,10 @@ class _TasksListScreenState extends ConsumerState onPressed: saving ? null : () async { - final title = titleController.text.trim(); + final title = + SubjectSuggestionEngine.normalizeDisplay( + titleController.text.trim(), + ); final description = descriptionController.text.trim(); final officeId = selectedOfficeId; if (title.isEmpty || officeId == null) { diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 6019c4fc..40b9b37e 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -890,6 +890,8 @@ class _TicketDetailScreenState extends ConsumerState { WidgetRef ref, Ticket ticket, ) async { + final screenContext = context; + final dialogShape = AppSurfaces.of(context).dialogShape; final officesAsync = ref.watch(officesOnceProvider); final subjectCtrl = TextEditingController(text: ticket.subject); final descCtrl = TextEditingController(text: ticket.description); @@ -898,85 +900,123 @@ class _TicketDetailScreenState extends ConsumerState { await showDialog( context: context, builder: (dialogContext) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: const Text('Edit Ticket'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: subjectCtrl, - decoration: const InputDecoration(labelText: 'Subject'), + var saving = false; + return StatefulBuilder( + builder: (dialogBuilderContext, setDialogState) { + return AlertDialog( + shape: dialogShape, + title: const Text('Edit Ticket'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: subjectCtrl, + enabled: !saving, + decoration: const InputDecoration(labelText: 'Subject'), + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Description', + ), + maxLines: 4, + ), + const SizedBox(height: 8), + 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 SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ), + ], ), - const SizedBox(height: 8), - TextField( - controller: descCtrl, - decoration: const InputDecoration(labelText: 'Description'), - maxLines: 4, + ), + actions: [ + TextButton( + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), ), - const SizedBox(height: 8), - 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, - ); - }, - loading: () => const SizedBox.shrink(), - error: (error, stack) => const SizedBox.shrink(), + ElevatedButton( + onPressed: saving + ? null + : () async { + final subject = subjectCtrl.text.trim(); + final desc = descCtrl.text.trim(); + setDialogState(() => saving = true); + try { + await ref + .read(ticketsControllerProvider) + .updateTicket( + ticketId: ticket.id, + subject: subject.isEmpty ? null : subject, + description: desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + ref.invalidate(ticketsProvider); + ref.invalidate(ticketByIdProvider(ticket.id)); + if (!dialogContext.mounted || + !screenContext.mounted) { + return; + } + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + screenContext, + 'Ticket updated', + ); + } catch (e) { + if (!screenContext.mounted) return; + showErrorSnackBar( + screenContext, + 'Failed to update ticket: $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 subject = subjectCtrl.text.trim(); - final desc = descCtrl.text.trim(); - try { - await ref - .read(ticketsControllerProvider) - .updateTicket( - ticketId: ticket.id, - subject: subject.isEmpty ? null : subject, - description: desc.isEmpty ? null : desc, - officeId: selectedOffice, - ); - if (!mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pop(); - showSuccessSnackBar(context, 'Ticket updated'); - }); - } catch (e) { - if (!mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - showErrorSnackBar(context, 'Failed to update ticket: $e'); - }); - } - }, - child: const Text('Save'), - ), - ], + ); + }, ); }, );