tasq/lib/screens/workforce/rotation_settings_dialog.dart

815 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.dart';
import '../../models/rotation_config.dart';
import '../../providers/profile_provider.dart';
import '../../providers/rotation_config_provider.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart';
/// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet
/// (mobile) based on screen width.
Future<void> showRotationSettings(BuildContext context, WidgetRef ref) async {
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
await m3ShowDialog(
context: context,
builder: (_) => const _RotationSettingsDialog(),
);
} else {
await m3ShowBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
maxChildSize: 0.95,
minChildSize: 0.5,
builder: (context, controller) =>
_RotationSettingsSheet(scrollController: controller),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// Dialog variant (desktop / tablet)
// ═══════════════════════════════════════════════════════════════
class _RotationSettingsDialog extends ConsumerStatefulWidget {
const _RotationSettingsDialog();
@override
ConsumerState<_RotationSettingsDialog> createState() =>
_RotationSettingsDialogState();
}
class _RotationSettingsDialogState
extends ConsumerState<_RotationSettingsDialog>
with SingleTickerProviderStateMixin {
late TabController _tabCtrl;
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final surfaces = AppSurfaces.of(context);
return Dialog(
shape: surfaces.dialogShape,
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 620),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
TabBar(
controller: _tabCtrl,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: const [
Tab(text: 'Rotation Order'),
Tab(text: 'Friday AM'),
Tab(text: 'Excluded'),
Tab(text: 'Initial Duty'),
],
),
Flexible(
child: TabBarView(
controller: _tabCtrl,
children: const [
_RotationOrderTab(),
_FridayAmTab(),
_ExcludedTab(),
_InitialDutyTab(),
],
),
),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 8, 4),
child: Row(
children: [
Icon(
Icons.settings_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Rotation Settings',
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// Bottom Sheet variant (mobile)
// ═══════════════════════════════════════════════════════════════
class _RotationSettingsSheet extends ConsumerStatefulWidget {
const _RotationSettingsSheet({required this.scrollController});
final ScrollController scrollController;
@override
ConsumerState<_RotationSettingsSheet> createState() =>
_RotationSettingsSheetState();
}
class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
with SingleTickerProviderStateMixin {
late TabController _tabCtrl;
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 8, 0),
child: Row(
children: [
Icon(
Icons.settings_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Rotation Settings',
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
TabBar(
controller: _tabCtrl,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: const [
Tab(text: 'Rotation Order'),
Tab(text: 'Friday AM'),
Tab(text: 'Excluded'),
Tab(text: 'Initial Duty'),
],
),
Expanded(
child: TabBarView(
controller: _tabCtrl,
children: const [
_RotationOrderTab(),
_FridayAmTab(),
_ExcludedTab(),
_InitialDutyTab(),
],
),
),
],
);
}
}
// ═══════════════════════════════════════════════════════════════
// Tab 1: IT Staff rotation order
// ═══════════════════════════════════════════════════════════════
class _RotationOrderTab extends ConsumerStatefulWidget {
const _RotationOrderTab();
@override
ConsumerState<_RotationOrderTab> createState() => _RotationOrderTabState();
}
class _RotationOrderTabState extends ConsumerState<_RotationOrderTab> {
List<String>? _order;
bool _saving = false;
List<Profile> _itStaff() {
return (ref.read(profilesProvider).valueOrNull ?? [])
.where((p) => p.role == 'it_staff')
.toList();
}
void _ensureOrder() {
if (_order != null) return;
final config = ref.read(rotationConfigProvider).valueOrNull;
final allIt = _itStaff();
if (config != null && config.rotationOrder.isNotEmpty) {
// Start with saved order, append any new staff not yet in order
final order = <String>[...config.rotationOrder];
for (final s in allIt) {
if (!order.contains(s.id)) order.add(s.id);
}
// Remove staff who no longer exist
order.removeWhere((id) => !allIt.any((p) => p.id == id));
_order = order;
} else {
_order = allIt.map((p) => p.id).toList();
}
}
@override
Widget build(BuildContext context) {
final configAsync = ref.watch(rotationConfigProvider);
final profilesAsync = ref.watch(profilesProvider);
if (configAsync.isLoading || profilesAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
_ensureOrder();
final allIt = _itStaff();
final profileMap = {for (final p in allIt) p.id: p};
final order = _order!;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Drag to reorder the IT Staff AM/PM duty rotation sequence.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: order.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = order.removeAt(oldIndex);
order.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
final id = order[index];
final profile = profileMap[id];
final name = profile?.fullName ?? id;
return M3FadeSlideIn(
key: ValueKey(id),
delay: Duration(milliseconds: 40 * index),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(name),
trailing: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
),
);
},
),
),
_SaveButton(saving: _saving, onSave: () => _save(context)),
],
);
}
Future<void> _save(BuildContext context) async {
setState(() => _saving = true);
try {
final current =
ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig();
await ref
.read(rotationConfigControllerProvider)
.save(current.copyWith(rotationOrder: _order));
if (mounted) showSuccessSnackBar(this.context, 'Rotation order saved.');
} catch (e) {
if (mounted) showErrorSnackBar(this.context, 'Save failed: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Tab 2: Friday AM rotation (non-Islam staff)
// ═══════════════════════════════════════════════════════════════
class _FridayAmTab extends ConsumerStatefulWidget {
const _FridayAmTab();
@override
ConsumerState<_FridayAmTab> createState() => _FridayAmTabState();
}
class _FridayAmTabState extends ConsumerState<_FridayAmTab> {
List<String>? _order;
bool _saving = false;
List<Profile> _nonIslamIt() {
return (ref.read(profilesProvider).valueOrNull ?? [])
.where((p) => p.role == 'it_staff' && p.religion != 'islam')
.toList();
}
void _ensureOrder() {
if (_order != null) return;
final config = ref.read(rotationConfigProvider).valueOrNull;
final eligible = _nonIslamIt();
if (config != null && config.fridayAmOrder.isNotEmpty) {
final order = <String>[...config.fridayAmOrder];
for (final s in eligible) {
if (!order.contains(s.id)) order.add(s.id);
}
order.removeWhere((id) => !eligible.any((p) => p.id == id));
_order = order;
} else {
_order = eligible.map((p) => p.id).toList();
}
}
@override
Widget build(BuildContext context) {
final configAsync = ref.watch(rotationConfigProvider);
final profilesAsync = ref.watch(profilesProvider);
if (configAsync.isLoading || profilesAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
_ensureOrder();
final eligible = _nonIslamIt();
final profileMap = {for (final p in eligible) p.id: p};
final order = _order!;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'On Fridays, only non-Muslim IT Staff can take AM duty. '
'Drag to set the rotation order.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (order.isEmpty)
Expanded(
child: Center(
child: Text(
'No eligible staff found.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
)
else
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: order.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = order.removeAt(oldIndex);
order.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
final id = order[index];
final profile = profileMap[id];
final name = profile?.fullName ?? id;
return M3FadeSlideIn(
key: ValueKey(id),
delay: Duration(milliseconds: 40 * index),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.secondaryContainer,
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(name),
trailing: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
),
);
},
),
),
_SaveButton(saving: _saving, onSave: () => _save(context)),
],
);
}
Future<void> _save(BuildContext context) async {
setState(() => _saving = true);
try {
final current =
ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig();
await ref
.read(rotationConfigControllerProvider)
.save(current.copyWith(fridayAmOrder: _order));
if (mounted) showSuccessSnackBar(this.context, 'Friday AM order saved.');
} catch (e) {
if (mounted) showErrorSnackBar(this.context, 'Save failed: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Tab 3: Excluded Staff
// ═══════════════════════════════════════════════════════════════
class _ExcludedTab extends ConsumerStatefulWidget {
const _ExcludedTab();
@override
ConsumerState<_ExcludedTab> createState() => _ExcludedTabState();
}
class _ExcludedTabState extends ConsumerState<_ExcludedTab> {
Set<String>? _excluded;
bool _saving = false;
List<Profile> _itStaff() {
return (ref.read(profilesProvider).valueOrNull ?? [])
.where((p) => p.role == 'it_staff')
.toList();
}
void _ensureExcluded() {
if (_excluded != null) return;
final config = ref.read(rotationConfigProvider).valueOrNull;
_excluded = config != null
? Set<String>.from(config.excludedStaffIds)
: <String>{};
}
@override
Widget build(BuildContext context) {
final configAsync = ref.watch(rotationConfigProvider);
final profilesAsync = ref.watch(profilesProvider);
if (configAsync.isLoading || profilesAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
_ensureExcluded();
final allIt = _itStaff();
final excluded = _excluded!;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Toggle to exclude IT Staff from duty rotation. '
'Excluded staff will not be assigned AM/PM/On-Call shifts.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: allIt.length,
itemBuilder: (context, index) {
final profile = allIt[index];
final isExcluded = excluded.contains(profile.id);
return M3FadeSlideIn(
key: ValueKey(profile.id),
delay: Duration(milliseconds: 40 * index),
child: SwitchListTile(
title: Text(
profile.fullName.isNotEmpty ? profile.fullName : profile.id,
),
subtitle: Text(
isExcluded ? 'Excluded' : 'Included in rotation',
style: TextStyle(
color: isExcluded
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
value: isExcluded,
onChanged: (val) {
setState(() {
if (val) {
excluded.add(profile.id);
} else {
excluded.remove(profile.id);
}
});
},
),
);
},
),
),
_SaveButton(saving: _saving, onSave: () => _save(context)),
],
);
}
Future<void> _save(BuildContext context) async {
setState(() => _saving = true);
try {
final current =
ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig();
await ref
.read(rotationConfigControllerProvider)
.save(current.copyWith(excludedStaffIds: _excluded!.toList()));
if (mounted) showSuccessSnackBar(this.context, 'Exclusions saved.');
} catch (e) {
if (mounted) showErrorSnackBar(this.context, 'Save failed: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Tab 4: Initial AM/PM duty
// ═══════════════════════════════════════════════════════════════
class _InitialDutyTab extends ConsumerStatefulWidget {
const _InitialDutyTab();
@override
ConsumerState<_InitialDutyTab> createState() => _InitialDutyTabState();
}
class _InitialDutyTabState extends ConsumerState<_InitialDutyTab> {
String? _amId;
String? _pmId;
bool _initialised = false;
bool _saving = false;
List<Profile> _itStaff() {
final config = ref.read(rotationConfigProvider).valueOrNull;
final excluded = config?.excludedStaffIds ?? [];
return (ref.read(profilesProvider).valueOrNull ?? [])
.where((p) => p.role == 'it_staff' && !excluded.contains(p.id))
.toList();
}
void _ensureInit() {
if (_initialised) return;
final config = ref.read(rotationConfigProvider).valueOrNull;
_amId = config?.initialAmStaffId;
_pmId = config?.initialPmStaffId;
_initialised = true;
}
@override
Widget build(BuildContext context) {
final configAsync = ref.watch(rotationConfigProvider);
final profilesAsync = ref.watch(profilesProvider);
if (configAsync.isLoading || profilesAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
_ensureInit();
final staff = _itStaff();
final colorScheme = Theme.of(context).colorScheme;
// Compute implied PM hint based on rotation order
String? impliedPmHint;
if (_amId != null) {
final config = ref.read(rotationConfigProvider).valueOrNull;
final order = config?.rotationOrder ?? staff.map((p) => p.id).toList();
if (order.length >= 3) {
final amIdx = order.indexOf(_amId!);
if (amIdx != -1) {
// PM is 2 positions after AM in the rotation order
final pmIdx = (amIdx + 2) % order.length;
final pmProfile = staff
.where((p) => p.id == order[pmIdx])
.firstOrNull;
if (pmProfile != null) {
impliedPmHint =
'Based on rotation: PM would be ${pmProfile.fullName}';
}
}
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Set the starting AM and PM duty assignment for the next '
'generated schedule. The rotation will continue from these '
'positions.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 18,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'AM and PM duty rotate with a gap of 2 positions. '
'e.g. If Staff A (pos 1) takes AM, Staff C (pos 3) takes PM.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
const SizedBox(height: 20),
Text(
'Initial AM Duty',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
DropdownButtonFormField<String?>(
initialValue: _amId,
decoration: const InputDecoration(
labelText: 'AM Staff',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Auto (continue from last week)'),
),
for (final s in staff)
DropdownMenuItem<String?>(
value: s.id,
child: Text(s.fullName.isNotEmpty ? s.fullName : s.id),
),
],
onChanged: (v) => setState(() => _amId = v),
),
const SizedBox(height: 20),
Text(
'Initial PM Duty',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
DropdownButtonFormField<String?>(
initialValue: _pmId,
decoration: const InputDecoration(
labelText: 'PM Staff',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Auto (continue from last week)'),
),
for (final s in staff)
DropdownMenuItem<String?>(
value: s.id,
child: Text(s.fullName.isNotEmpty ? s.fullName : s.id),
),
],
onChanged: (v) => setState(() => _pmId = v),
),
if (impliedPmHint != null) ...[
const SizedBox(height: 8),
Text(
impliedPmHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
],
const SizedBox(height: 24),
_SaveButton(saving: _saving, onSave: () => _save(context)),
],
),
);
}
Future<void> _save(BuildContext context) async {
setState(() => _saving = true);
try {
final current =
ref.read(rotationConfigProvider).valueOrNull ?? RotationConfig();
await ref
.read(rotationConfigControllerProvider)
.save(
current.copyWith(
initialAmStaffId: _amId,
initialPmStaffId: _pmId,
clearInitialAm: _amId == null,
clearInitialPm: _pmId == null,
),
);
if (mounted) showSuccessSnackBar(this.context, 'Initial duty saved.');
} catch (e) {
if (mounted) showErrorSnackBar(this.context, 'Save failed: $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
// ═══════════════════════════════════════════════════════════════
// Shared Save Button
// ═══════════════════════════════════════════════════════════════
class _SaveButton extends StatelessWidget {
const _SaveButton({required this.saving, required this.onSave});
final bool saving;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: saving ? null : onSave,
icon: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save'),
),
),
);
}
}