1448 lines
49 KiB
Dart
1448 lines
49 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 '../../services/holidays_service.dart';
|
|
import '../../utils/app_time.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: 6, 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'),
|
|
Tab(text: 'Shift Types'),
|
|
Tab(text: 'Holidays'),
|
|
],
|
|
),
|
|
Flexible(
|
|
child: TabBarView(
|
|
controller: _tabCtrl,
|
|
children: const [
|
|
_RotationOrderTab(),
|
|
_FridayAmTab(),
|
|
_ExcludedTab(),
|
|
_InitialDutyTab(),
|
|
_ShiftTypesTab(),
|
|
_HolidaySettingsTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: 6, 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'),
|
|
Tab(text: 'Shift Types'),
|
|
Tab(text: 'Holidays'),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabCtrl,
|
|
children: const [
|
|
_RotationOrderTab(),
|
|
_FridayAmTab(),
|
|
_ExcludedTab(),
|
|
_InitialDutyTab(),
|
|
_ShiftTypesTab(),
|
|
_HolidaySettingsTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// 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: 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'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Tab 5: Shift Types (configurable shift type definitions & role mapping)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
class _ShiftTypesTab extends ConsumerStatefulWidget {
|
|
const _ShiftTypesTab();
|
|
|
|
@override
|
|
ConsumerState<_ShiftTypesTab> createState() => _ShiftTypesTabState();
|
|
}
|
|
|
|
class _ShiftTypesTabState extends ConsumerState<_ShiftTypesTab> {
|
|
List<ShiftTypeConfig>? _shiftTypes;
|
|
Map<String, int> _weeklyHours = {};
|
|
bool _saving = false;
|
|
|
|
static const _knownRoles = [
|
|
'it_staff',
|
|
'admin',
|
|
'dispatcher',
|
|
'programmer',
|
|
'standard',
|
|
];
|
|
|
|
void _ensureLoaded() {
|
|
if (_shiftTypes != null) return;
|
|
final config = ref.read(rotationConfigProvider).valueOrNull;
|
|
|
|
// The default rotation config uses const shift type definitions.
|
|
// Make a mutable copy so the UI can edit allowedRoles safely.
|
|
final baseShiftTypes = config?.shiftTypes ?? RotationConfig().shiftTypes;
|
|
_shiftTypes = baseShiftTypes
|
|
.map(
|
|
(s) => ShiftTypeConfig(
|
|
id: s.id,
|
|
label: s.label,
|
|
startHour: s.startHour,
|
|
startMinute: s.startMinute,
|
|
durationMinutes: s.durationMinutes,
|
|
allowedRoles: List<String>.from(s.allowedRoles),
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
_weeklyHours = config?.roleWeeklyHours ?? {};
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final configAsync = ref.watch(rotationConfigProvider);
|
|
if (configAsync.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
_ensureLoaded();
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Weekly hours per role (hard cap). Leave blank for no limit.',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
for (final role in _knownRoles)
|
|
SizedBox(
|
|
width: 140,
|
|
child: TextFormField(
|
|
initialValue: _weeklyHours[role]?.toString() ?? '',
|
|
decoration: InputDecoration(
|
|
labelText: role,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (value) {
|
|
final hours = int.tryParse(value);
|
|
setState(() {
|
|
if (hours != null) {
|
|
_weeklyHours[role] = hours;
|
|
} else {
|
|
_weeklyHours.remove(role);
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _shiftTypes!.length,
|
|
separatorBuilder: (context, index) => const Divider(),
|
|
itemBuilder: (context, index) {
|
|
final shift = _shiftTypes![index];
|
|
final startTime = TimeOfDay(
|
|
hour: shift.startHour,
|
|
minute: shift.startMinute,
|
|
);
|
|
return ExpansionTile(
|
|
key: ValueKey(shift.id),
|
|
title: Text(shift.label),
|
|
subtitle: Text('ID: ${shift.id}'),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Start: ${startTime.format(context)}',
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'Duration: ${shift.durationMinutes ~/ 60}h ${shift.durationMinutes % 60}m',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Allowed roles',
|
|
style: Theme.of(context).textTheme.labelLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final role in _knownRoles)
|
|
FilterChip(
|
|
label: Text(role),
|
|
selected: shift.allowedRoles.contains(role),
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
if (selected) {
|
|
shift.allowedRoles.add(role);
|
|
} else {
|
|
shift.allowedRoles.remove(role);
|
|
}
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => _editShiftType(context, index),
|
|
icon: const Icon(Icons.edit, size: 18),
|
|
label: const Text('Edit'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _removeShiftType(index),
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
label: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _addShiftType,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add shift type'),
|
|
),
|
|
const Spacer(),
|
|
_SaveButton(saving: _saving, onSave: () => _save(context)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _addShiftType() async {
|
|
final newShift = ShiftTypeConfig(
|
|
id: 'new_shift_${DateTime.now().millisecondsSinceEpoch}',
|
|
label: 'New Shift',
|
|
startHour: 8,
|
|
startMinute: 0,
|
|
durationMinutes: 480,
|
|
noonBreakMinutes: 60,
|
|
allowedRoles: <String>[],
|
|
);
|
|
setState(() {
|
|
_shiftTypes = [...?_shiftTypes, newShift];
|
|
});
|
|
await _editShiftType(context, _shiftTypes!.length - 1);
|
|
}
|
|
|
|
Future<void> _removeShiftType(int index) async {
|
|
setState(() {
|
|
_shiftTypes = [...?_shiftTypes]..removeAt(index);
|
|
});
|
|
}
|
|
|
|
Future<void> _editShiftType(BuildContext context, int index) async {
|
|
final shift = _shiftTypes![index];
|
|
var label = shift.label;
|
|
var start = TimeOfDay(hour: shift.startHour, minute: shift.startMinute);
|
|
var durationMinutes = shift.durationMinutes;
|
|
var noonBreakMinutes = shift.noonBreakMinutes;
|
|
|
|
final confirmed = await m3ShowDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Edit Shift Type'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextFormField(
|
|
initialValue: label,
|
|
decoration: const InputDecoration(labelText: 'Label'),
|
|
onChanged: (v) => setState(() => label = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
InkWell(
|
|
onTap: () async {
|
|
final picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: start,
|
|
);
|
|
if (picked != null) setState(() => start = picked);
|
|
},
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Start time',
|
|
),
|
|
child: Text(start.format(context)),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
initialValue: (durationMinutes ~/ 60).toString(),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Hours (total)',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (v) {
|
|
final h = int.tryParse(v) ?? 0;
|
|
setState(() {
|
|
durationMinutes =
|
|
h * 60 + (durationMinutes % 60);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextFormField(
|
|
initialValue: (durationMinutes % 60).toString(),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Minutes',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (v) {
|
|
final m = int.tryParse(v) ?? 0;
|
|
setState(() {
|
|
durationMinutes =
|
|
(durationMinutes ~/ 60) * 60 + m;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
initialValue: noonBreakMinutes.toString(),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Noon break (minutes)',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (v) {
|
|
final m = int.tryParse(v) ?? 0;
|
|
setState(() {
|
|
noonBreakMinutes = m;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (confirmed != true) return;
|
|
|
|
setState(() {
|
|
_shiftTypes = [...?_shiftTypes];
|
|
_shiftTypes![index] = ShiftTypeConfig(
|
|
id: shift.id,
|
|
label: label,
|
|
startHour: start.hour,
|
|
startMinute: start.minute,
|
|
durationMinutes: durationMinutes,
|
|
noonBreakMinutes: noonBreakMinutes,
|
|
allowedRoles: shift.allowedRoles,
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _save(BuildContext context) async {
|
|
final ctx = context;
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref
|
|
.read(rotationConfigControllerProvider)
|
|
.updateShiftTypes(_shiftTypes!);
|
|
await ref
|
|
.read(rotationConfigControllerProvider)
|
|
.updateRoleWeeklyHours(_weeklyHours);
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showSuccessSnackBar(ctx, 'Shift types saved.');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showErrorSnackBar(ctx, 'Save failed: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Tab 6: Holiday settings (sync + custom holidays)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
class _HolidaySettingsTab extends ConsumerStatefulWidget {
|
|
const _HolidaySettingsTab();
|
|
|
|
@override
|
|
ConsumerState<_HolidaySettingsTab> createState() =>
|
|
_HolidaySettingsTabState();
|
|
}
|
|
|
|
class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> {
|
|
bool _syncEnabled = false;
|
|
int _year = DateTime.now().year;
|
|
bool _saving = false;
|
|
List<Holiday> _holidays = [];
|
|
bool _loaded = false;
|
|
|
|
void _ensureLoaded() {
|
|
if (_loaded) return;
|
|
final config = ref.read(rotationConfigProvider).valueOrNull;
|
|
_holidays = config?.holidays ?? [];
|
|
_syncEnabled = config?.syncPhilippinesHolidays ?? false;
|
|
_year = config?.holidaysYear ?? DateTime.now().year;
|
|
_loaded = true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final configAsync = ref.watch(rotationConfigProvider);
|
|
if (configAsync.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
_ensureLoaded();
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Holidays are used to flag shifts that occur on special dates. '
|
|
'You can sync Philippine national holidays or add custom dates.',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
value: _syncEnabled,
|
|
title: const Text('Sync Philippine holidays'),
|
|
onChanged: (value) => setState(() {
|
|
_syncEnabled = value;
|
|
}),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
SizedBox(
|
|
width: 120,
|
|
child: TextFormField(
|
|
initialValue: _year.toString(),
|
|
decoration: const InputDecoration(labelText: 'Year'),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (value) {
|
|
final parsed = int.tryParse(value);
|
|
if (parsed != null) {
|
|
setState(() => _year = parsed);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
FilledButton(
|
|
onPressed: _syncEnabled ? _syncHolidays : null,
|
|
child: const Text('Sync now'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
OutlinedButton(
|
|
onPressed: _addCustomHoliday,
|
|
child: const Text('Add custom holiday'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _holidays.length,
|
|
separatorBuilder: (context, index) => const Divider(),
|
|
itemBuilder: (context, index) {
|
|
final holiday = _holidays[index];
|
|
return ListTile(
|
|
title: Text(holiday.name),
|
|
subtitle: Text(AppTime.formatDate(holiday.date)),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: () => _removeHoliday(index),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
_SaveButton(saving: _saving, onSave: () => _save(context)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _syncHolidays() async {
|
|
final ctx = context;
|
|
setState(() => _saving = true);
|
|
try {
|
|
final holidays = await HolidaysService.fetchPhilippinesHolidays(_year);
|
|
setState(() {
|
|
_holidays = holidays;
|
|
});
|
|
await ref
|
|
.read(rotationConfigControllerProvider)
|
|
.setHolidaySync(
|
|
enabled: _syncEnabled,
|
|
year: _year,
|
|
holidays: _holidays,
|
|
);
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showSuccessSnackBar(ctx, 'Holidays synced for $_year.');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showErrorSnackBar(ctx, 'Sync failed: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _addCustomHoliday() async {
|
|
DateTime selectedDate = DateTime.now();
|
|
String name = '';
|
|
|
|
final confirmed = await m3ShowDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Add custom holiday'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
InkWell(
|
|
onTap: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: selectedDate,
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
if (picked != null) {
|
|
setState(() => selectedDate = picked);
|
|
}
|
|
},
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(labelText: 'Date'),
|
|
child: Text(AppTime.formatDate(selectedDate)),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
decoration: const InputDecoration(labelText: 'Name'),
|
|
onChanged: (value) => setState(() => name = value),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('Add'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (confirmed != true || name.isEmpty) return;
|
|
|
|
setState(() {
|
|
_holidays = [
|
|
..._holidays,
|
|
Holiday(date: selectedDate, name: name, source: 'custom'),
|
|
];
|
|
});
|
|
}
|
|
|
|
void _removeHoliday(int index) {
|
|
setState(() {
|
|
_holidays = [..._holidays]..removeAt(index);
|
|
});
|
|
}
|
|
|
|
Future<void> _save(BuildContext context) async {
|
|
final ctx = context;
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref
|
|
.read(rotationConfigControllerProvider)
|
|
.setHolidaySync(
|
|
enabled: _syncEnabled,
|
|
year: _year,
|
|
holidays: _holidays,
|
|
);
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showSuccessSnackBar(ctx, 'Holidays saved.');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
// ignore: use_build_context_synchronously
|
|
showErrorSnackBar(ctx, 'Save failed: $e');
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
}
|