Wrapped Offices and Services dropdown with consumer to watch if they are already available

This commit is contained in:
Marc Rejohn Castillano 2026-02-28 19:12:58 +08:00
parent 6882fdcac8
commit e75d61ac64
2 changed files with 168 additions and 50 deletions

View File

@ -175,10 +175,10 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
final servicesAsync = ref.watch(servicesOnceProvider);
bool saving = false; bool saving = false;
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final servicesAsync = ref.watch(servicesOnceProvider);
return AlertDialog( return AlertDialog(
shape: AppSurfaces.of(context).dialogShape, shape: AppSurfaces.of(context).dialogShape,
title: Text(office == null ? 'Create Office' : 'Edit Office'), title: Text(office == null ? 'Create Office' : 'Edit Office'),
@ -194,35 +194,43 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
enabled: !saving, enabled: !saving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
servicesAsync.when( Consumer(
data: (services) { builder: (ctx, dialogRef, _) {
return DropdownButtonFormField<String?>( final servicesAsync = dialogRef.watch(
initialValue: selectedServiceId, servicesOnceProvider,
decoration: const InputDecoration( );
labelText: 'Service', return servicesAsync.when(
), data: (services) {
items: [ return DropdownButtonFormField<String?>(
const DropdownMenuItem<String?>( initialValue: selectedServiceId,
value: null, decoration: const InputDecoration(
child: Text('None'), labelText: 'Service',
),
...services.map(
(s) => DropdownMenuItem<String?>(
value: s.id,
child: Text(s.name),
), ),
), items: [
], const DropdownMenuItem<String?>(
onChanged: saving value: null,
? null child: Text('None'),
: (v) => setState(() => selectedServiceId = v), ),
...services.map(
(s) => DropdownMenuItem<String?>(
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'),
), ),
], ],
), ),

View File

@ -117,6 +117,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
'queued', 'queued',
'in_progress', 'in_progress',
'completed', 'completed',
'cancelled',
]; ];
String? _mentionQuery; String? _mentionQuery;
int? _mentionStart; int? _mentionStart;
@ -2765,7 +2766,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
WidgetRef ref, WidgetRef ref,
Task task, Task task,
) async { ) 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 titleCtrl = TextEditingController(text: task.title);
final descCtrl = TextEditingController(text: task.description); final descCtrl = TextEditingController(text: task.description);
String? selectedOffice = task.officeId; String? selectedOffice = task.officeId;
@ -2791,30 +2793,45 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
maxLines: 4, maxLines: 4,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
officesAsync.when( Consumer(
data: (offices) { builder: (dialogContext, dialogRef, _) {
final officesSorted = List<Office>.from(offices) final officesAsync = dialogRef.watch(officesOnceProvider);
..sort( return officesAsync.when(
(a, b) => a.name.toLowerCase().compareTo( data: (offices) {
b.name.toLowerCase(), final officesSorted = List<Office>.from(offices)
), ..sort(
); (a, b) => a.name.toLowerCase().compareTo(
return DropdownButtonFormField<String?>( b.name.toLowerCase(),
initialValue: selectedOffice, ),
decoration: const InputDecoration(labelText: 'Office'), );
items: [ return DropdownButtonFormField<String?>(
const DropdownMenuItem( initialValue: selectedOffice,
value: null, decoration: const InputDecoration(
child: Text('Unassigned'), labelText: 'Office',
), ),
for (final o in officesSorted) items: [
DropdownMenuItem(value: o.id, child: Text(o.name)), const DropdownMenuItem(
], value: null,
onChanged: (v) => selectedOffice = v, 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<TaskDetailScreen>
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: (value) async { onSelected: (value) async {
// If cancelling, require a reason show dialog with spinner.
if (value == 'cancelled') {
final reasonCtrl = TextEditingController();
await showDialog<void>(
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 // Update DB only Supabase realtime stream will emit the
// updated task list, so explicit invalidation here causes a // updated task list, so explicit invalidation here causes a
// visible loading/refresh and is unnecessary. // visible loading/refresh and is unnecessary.