diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index ed1111cd..301bc33b 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -175,10 +175,10 @@ class _OfficesScreenState extends ConsumerState { await showDialog( context: context, builder: (dialogContext) { - final servicesAsync = ref.watch(servicesOnceProvider); bool saving = false; return StatefulBuilder( builder: (context, setState) { + final servicesAsync = ref.watch(servicesOnceProvider); return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: Text(office == null ? 'Create Office' : 'Edit Office'), @@ -194,35 +194,43 @@ class _OfficesScreenState extends ConsumerState { enabled: !saving, ), const SizedBox(height: 12), - servicesAsync.when( - data: (services) { - return DropdownButtonFormField( - initialValue: selectedServiceId, - decoration: const InputDecoration( - labelText: 'Service', - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('None'), - ), - ...services.map( - (s) => DropdownMenuItem( - value: s.id, - child: Text(s.name), + Consumer( + builder: (ctx, dialogRef, _) { + final servicesAsync = dialogRef.watch( + servicesOnceProvider, + ); + return servicesAsync.when( + data: (services) { + return DropdownButtonFormField( + initialValue: selectedServiceId, + decoration: const InputDecoration( + labelText: 'Service', ), - ), - ], - onChanged: saving - ? null - : (v) => setState(() => selectedServiceId = v), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + ...services.map( + (s) => DropdownMenuItem( + value: s.id, + child: Text(s.name), + ), + ), + ], + onChanged: saving + ? null + : (v) => + setState(() => selectedServiceId = v), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: LinearProgressIndicator(), + ), + error: (e, _) => Text('Failed to load services: $e'), ); }, - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: LinearProgressIndicator(), - ), - error: (e, _) => Text('Failed to load services: $e'), ), ], ), diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index fb84b1c1..b4345ddb 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -117,6 +117,7 @@ class _TaskDetailScreenState extends ConsumerState 'queued', 'in_progress', 'completed', + 'cancelled', ]; String? _mentionQuery; int? _mentionStart; @@ -2765,7 +2766,8 @@ class _TaskDetailScreenState extends ConsumerState WidgetRef ref, Task task, ) async { - final officesAsync = ref.watch(officesOnceProvider); + // 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); String? selectedOffice = task.officeId; @@ -2791,30 +2793,45 @@ class _TaskDetailScreenState extends ConsumerState 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: (v) => selectedOffice = v, + 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, + ); + }, + // 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(), + ), + error: (error, _) => const SizedBox.shrink(), ); }, - loading: () => const SizedBox.shrink(), - error: (error, _) => const SizedBox.shrink(), ), ], ), @@ -2889,6 +2906,99 @@ class _TaskDetailScreenState extends ConsumerState return PopupMenuButton( onSelected: (value) async { + // If cancelling, require a reason — show dialog with spinner. + if (value == 'cancelled') { + final reasonCtrl = TextEditingController(); + await showDialog( + context: context, + builder: (dialogContext) { + var isSaving = false; + return StatefulBuilder( + builder: (ctx, setState) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Cancel task'), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 12), + content: SizedBox( + width: 360, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: TextField( + controller: reasonCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Reason', + hintText: 'Provide a justification for cancelling', + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: isSaving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isSaving + ? null + : () async { + final reason = reasonCtrl.text.trim(); + if (reason.isEmpty) { + showErrorSnackBar( + context, + 'Cancellation requires a reason.', + ); + return; + } + setState(() => isSaving = true); + try { + await ref + .read(tasksControllerProvider) + .updateTaskStatus( + taskId: task.id, + status: 'cancelled', + reason: reason, + ); + if (context.mounted) { + showSuccessSnackBar( + context, + 'Task cancelled', + ); + Navigator.of(dialogContext).pop(); + } + } catch (e) { + if (context.mounted) { + showErrorSnackBar(context, e.toString()); + } + } finally { + if (context.mounted) + setState(() => isSaving = false); + } + }, + child: isSaving + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.onPrimary, + ), + ), + ) + : const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + return; + } + // Update DB only — Supabase realtime stream will emit the // updated task list, so explicit invalidation here causes a // visible loading/refresh and is unnecessary.