Files
krow-worker-app/lib/state/availability_controller.dart
2026-10-05 22:14:58 +05:30

139 lines
4.3 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/availability.dart';
import 'session_controller.dart';
/// Availability is stored as `yyyy-mm-dd` -> what the worker said about it.
///
/// Days the worker has not answered are simply absent, and read back as
/// unavailable: nobody is on the hook for a day they never opened.
typedef AvailabilityMap = Map<String, DayPlan>;
String dayKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
class AvailabilityController extends Notifier<AvailabilityMap> {
static const _key = 'availability_v2';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
AvailabilityMap build() {
final raw = _prefs.getStringList(_key) ?? const <String>[];
final map = <String, DayPlan>{};
for (final entry in raw) {
// Stored as "2026-10-05:prefers:9-17", or "…:available", or
// "…:prefers:any" when no hours were given.
final parts = entry.split(':');
if (parts.length < 2) continue;
final status = DayAvailability.values.firstWhere(
(s) => s.name == parts[1],
orElse: () => DayAvailability.unavailable,
);
if (status == DayAvailability.unavailable) continue;
map[parts[0]] = DayPlan(
status: status,
window: parts.length > 2 ? _parseWindow(parts[2]) : PreferredWindow.any,
);
}
return map;
}
static PreferredWindow _parseWindow(String raw) {
if (raw == 'any') return PreferredWindow.any;
final ends = raw.split('-');
if (ends.length != 2) return PreferredWindow.any;
return PreferredWindow(
from: int.tryParse(ends[0]),
to: int.tryParse(ends[1]),
);
}
static String _encodeWindow(PreferredWindow w) =>
w.isAnyTime ? 'any' : '${w.from ?? ''}-${w.to ?? ''}';
void _persist(AvailabilityMap map) {
_prefs.setStringList(_key, [
for (final e in map.entries)
if (e.value.isSet)
'${e.key}:${e.value.status.name}:${_encodeWindow(e.value.window)}',
]);
state = map;
}
AvailabilityMap get _copy => {for (final e in state.entries) e.key: e.value};
DayPlan planFor(DateTime day) => state[dayKey(day)] ?? const DayPlan();
bool isDayOff(DateTime day) => !planFor(day).isSet;
void setStatus(DateTime day, DayAvailability status) {
final map = _copy;
final key = dayKey(day);
if (status == DayAvailability.unavailable) {
map.remove(key);
} else {
// The window rides along, so a worker who flips to Unavailable and back
// does not have to set their hours a second time.
map[key] = (map[key] ?? const DayPlan()).copyWith(status: status);
}
_persist(map);
}
void setWindow(DateTime day, PreferredWindow window) {
final key = dayKey(day);
final current = state[key];
// A window without a status to hang it on is not a day the worker has
// answered, so there is nothing to store.
if (current == null) return;
final map = _copy..[key] = current.copyWith(window: window);
_persist(map);
}
/// Presets across the visible week: all days, weekdays only, weekends only.
/// Days outside the preset are cleared, which is what makes the four
/// buttons read as four answers rather than four additive filters.
void applyPreset(
List<DateTime> week, {
required bool Function(DateTime) test,
}) {
final map = _copy;
for (final day in week) {
final key = dayKey(day);
if (test(day)) {
map[key] = (map[key] ?? const DayPlan()).copyWith(
status: DayAvailability.available,
);
} else {
map.remove(key);
}
}
_persist(map);
}
void clearWeek(List<DateTime> week) {
final map = _copy;
for (final day in week) {
map.remove(dayKey(day));
}
_persist(map);
}
}
final availabilityProvider =
NotifierProvider<AvailabilityController, AvailabilityMap>(
AvailabilityController.new,
);
/// How many days in the coming week the worker has said yes to, either way.
final daysSetProvider = Provider<int>((ref) {
final map = ref.watch(availabilityProvider);
final now = DateTime.now();
var count = 0;
for (var i = 0; i < 7; i++) {
if (map[dayKey(now.add(Duration(days: i)))]?.isSet ?? false) count++;
}
return count;
});