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>(
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<OfficesScreen> {
enabled: !saving,
),
const SizedBox(height: 12),
servicesAsync.when(
data: (services) {
return DropdownButtonFormField<String?>(
initialValue: selectedServiceId,
decoration: const InputDecoration(
labelText: 'Service',
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None'),
),
...services.map(
(s) => DropdownMenuItem<String?>(
value: s.id,
child: Text(s.name),
Consumer(
builder: (ctx, dialogRef, _) {
final servicesAsync = dialogRef.watch(
servicesOnceProvider,
);
return servicesAsync.when(
data: (services) {
return DropdownButtonFormField<String?>(
initialValue: selectedServiceId,
decoration: const InputDecoration(
labelText: 'Service',
),
),
],
onChanged: saving
? null
: (v) => setState(() => selectedServiceId = v),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('None'),
),
...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',
'in_progress',
'completed',
'cancelled',
];
String? _mentionQuery;
int? _mentionStart;
@ -2765,7 +2766,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
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<TaskDetailScreen>
maxLines: 4,
),
const SizedBox(height: 8),
officesAsync.when(
data: (offices) {
final officesSorted = List<Office>.from(offices)
..sort(
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
return DropdownButtonFormField<String?>(
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<Office>.from(offices)
..sort(
(a, b) => a.name.toLowerCase().compareTo(
b.name.toLowerCase(),
),
);
return DropdownButtonFormField<String?>(
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<TaskDetailScreen>
return PopupMenuButton<String>(
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
// updated task list, so explicit invalidation here causes a
// visible loading/refresh and is unnecessary.