Initial commit: Krow Worker App

This commit is contained in:
R-Bharathraj
2026-09-17 22:26:11 +05:30
commit cafffd27e0
113 changed files with 13067 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
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` -> the blocks switched on that day.
typedef AvailabilityMap = Map<String, Set<DayBlock>>;
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';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
AvailabilityMap build() {
final raw = _prefs.getStringList(_key) ?? const <String>[];
final map = <String, Set<DayBlock>>{};
for (final entry in raw) {
// Stored as "2026-08-14:morning,evening".
final parts = entry.split(':');
if (parts.length != 2) continue;
final blocks = parts[1]
.split(',')
.where((s) => s.isNotEmpty)
.map(
(name) => DayBlock.values.firstWhere(
(b) => b.name == name,
orElse: () => DayBlock.morning,
),
)
.toSet();
if (blocks.isNotEmpty) map[parts[0]] = blocks;
}
return map;
}
void _persist(AvailabilityMap map) {
_prefs.setStringList(_key, [
for (final e in map.entries)
if (e.value.isNotEmpty)
'${e.key}:${e.value.map((b) => b.name).join(',')}',
]);
state = map;
}
Set<DayBlock> blocksFor(DateTime day) => state[dayKey(day)] ?? <DayBlock>{};
bool isDayOff(DateTime day) => blocksFor(day).isEmpty;
void toggleBlock(DateTime day, DayBlock block) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
final key = dayKey(day);
final blocks = map[key] ?? <DayBlock>{};
if (!blocks.remove(block)) blocks.add(block);
if (blocks.isEmpty) {
map.remove(key);
} else {
map[key] = blocks;
}
_persist(map);
}
/// The day toggle: all three blocks on, or the day cleared.
void setDayAvailable(DateTime day, bool available) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
final key = dayKey(day);
if (available) {
map[key] = DayBlock.values.toSet();
} else {
map.remove(key);
}
_persist(map);
}
/// Presets across the visible week: all days, weekdays only, weekends only.
void applyPreset(
List<DateTime> week, {
required bool Function(DateTime) test,
}) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
for (final day in week) {
final key = dayKey(day);
if (test(day)) {
map[key] = DayBlock.values.toSet();
} else {
map.remove(key);
}
}
_persist(map);
}
void clearWeek(List<DateTime> week) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
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 have at least one block switched on.
final daysSetProvider = Provider<int>((ref) {
final map = ref.watch(availabilityProvider);
final now = DateTime.now();
var count = 0;
for (var i = 0; i < 7; i++) {
final d = now.add(Duration(days: i));
if ((map[dayKey(d)] ?? const <DayBlock>{}).isNotEmpty) count++;
}
return count;
});

View File

@@ -0,0 +1,178 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/compliance.dart';
import 'seed.dart';
/// The four compliance sections, each a list the worker can act on.
enum ComplianceSection { documents, certificates, taxForms, wardrobe }
extension ComplianceSectionX on ComplianceSection {
String get title => switch (this) {
ComplianceSection.documents => 'Documents',
ComplianceSection.certificates => 'Certificates',
ComplianceSection.taxForms => 'Tax forms',
ComplianceSection.wardrobe => 'Attire',
};
String get subtitle => switch (this) {
ComplianceSection.documents => 'ID, SSN, work permit, address',
ComplianceSection.certificates => 'Food handler, RBS and more',
ComplianceSection.taxForms => 'Form I-9 and Form W-4',
ComplianceSection.wardrobe => "Photos of what you'll wear",
};
}
class ComplianceData {
const ComplianceData({
required this.documents,
required this.certificates,
required this.taxForms,
required this.wardrobe,
});
final List<ComplianceItem> documents;
final List<ComplianceItem> certificates;
final List<ComplianceItem> taxForms;
final List<ComplianceItem> wardrobe;
List<ComplianceItem> section(ComplianceSection s) => switch (s) {
ComplianceSection.documents => documents,
ComplianceSection.certificates => certificates,
ComplianceSection.taxForms => taxForms,
ComplianceSection.wardrobe => wardrobe,
};
List<ComplianceItem> get all => [
...documents,
...certificates,
...taxForms,
...wardrobe,
];
ComplianceData copyWith({
List<ComplianceItem>? documents,
List<ComplianceItem>? certificates,
List<ComplianceItem>? taxForms,
List<ComplianceItem>? wardrobe,
}) => ComplianceData(
documents: documents ?? this.documents,
certificates: certificates ?? this.certificates,
taxForms: taxForms ?? this.taxForms,
wardrobe: wardrobe ?? this.wardrobe,
);
}
class ComplianceController extends Notifier<ComplianceData> {
@override
ComplianceData build() => ComplianceData(
documents: Seed.documents(),
certificates: Seed.certificates(),
taxForms: Seed.taxForms(),
wardrobe: Seed.wardrobe(),
);
List<ComplianceItem> _apply(
List<ComplianceItem> list,
String id,
ComplianceItem Function(ComplianceItem) f,
) => [
for (final i in list)
if (i.id == id) f(i) else i,
];
void _update(
ComplianceSection section,
String id,
ComplianceItem Function(ComplianceItem) f,
) {
final s = state;
state = switch (section) {
ComplianceSection.documents => s.copyWith(
documents: _apply(s.documents, id, f),
),
ComplianceSection.certificates => s.copyWith(
certificates: _apply(s.certificates, id, f),
),
ComplianceSection.taxForms => s.copyWith(
taxForms: _apply(s.taxForms, id, f),
),
ComplianceSection.wardrobe => s.copyWith(
wardrobe: _apply(s.wardrobe, id, f),
),
};
}
/// Stand-in for the camera / file picker: attaching a file sends the item
/// back for review.
void uploadFile(ComplianceSection section, String id, String fileName) {
_update(
section,
id,
(i) => i.copyWith(
state: ComplianceState.pending,
statusLabel: 'In review',
fileName: fileName,
fileMeta: 'Uploaded just now',
),
);
}
void removeFile(ComplianceSection section, String id) {
_update(
section,
id,
(i) => ComplianceItem(
id: i.id,
title: i.title,
subtitle: i.subtitle,
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: i.required_,
),
);
}
void setExpiry(ComplianceSection section, String id, DateTime date) {
_update(section, id, (i) => i.copyWith(expires: date));
}
ComplianceItem? find(ComplianceSection section, String id) {
for (final i in state.section(section)) {
if (i.id == id) return i;
}
return null;
}
}
final complianceProvider =
NotifierProvider<ComplianceController, ComplianceData>(
ComplianceController.new,
);
/// `(verified, total)` across every required item — what the hub reports.
final complianceProgressProvider = Provider<(int, int)>((ref) {
final items = ref.watch(complianceProvider).all.where((i) => i.required_);
final total = items.length;
final done = items.where((i) => i.state == ComplianceState.verified).length;
return (done, total);
});
/// Whether the worker is cleared to take shifts at all.
final isCompliantProvider = Provider<bool>((ref) {
final (done, total) = ref.watch(complianceProgressProvider);
return done == total;
});
final sectionProgressProvider = Provider.family<(int, int), ComplianceSection>((
ref,
section,
) {
final items = ref
.watch(complianceProvider)
.section(section)
.where((i) => i.required_);
return (
items.where((i) => i.state == ComplianceState.verified).length,
items.length,
);
});

View File

@@ -0,0 +1,168 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/compliance.dart';
import '../models/shift.dart';
import '../models/worker.dart';
import 'session_controller.dart';
import 'shifts_controller.dart';
class ProfileState {
const ProfileState({
required this.worker,
required this.settings,
required this.skills,
required this.contacts,
this.bank,
this.newsletter = false,
});
final Worker worker;
/// Settings the worker has worked in, and the trades they can do.
final Set<String> settings;
final Set<String> skills;
final List<EmergencyContact> contacts;
final BankAccount? bank;
final bool newsletter;
ProfileState copyWith({
Worker? worker,
Set<String>? settings,
Set<String>? skills,
List<EmergencyContact>? contacts,
BankAccount? bank,
bool? newsletter,
}) => ProfileState(
worker: worker ?? this.worker,
settings: settings ?? this.settings,
skills: skills ?? this.skills,
contacts: contacts ?? this.contacts,
bank: bank ?? this.bank,
newsletter: newsletter ?? this.newsletter,
);
}
class ProfileController extends Notifier<ProfileState> {
static const _kSettings = 'profile_settings';
static const _kSkills = 'profile_skills';
static const _kNewsletter = 'profile_newsletter';
static const _kName = 'profile_name';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
ProfileState build() {
final p = _prefs;
final phone = ref.watch(sessionProvider).phone;
return ProfileState(
worker: Worker(
name: p.getString(_kName) ?? 'Iliana Ortega',
role: 'Barista',
city: 'San Jose',
phone: phone ?? '+1 408 555 0148',
onTimePercent: 50,
noShows: 1,
cancellations: 1,
),
settings:
(p.getStringList(_kSettings) ??
const ['Hospitality', 'Events', 'Catering'])
.toSet(),
skills:
(p.getStringList(_kSkills) ??
const ['Barista', 'Customer service', 'Food service'])
.toSet(),
contacts: [
EmergencyContact(
name: 'Carlos Ortega',
phone: '+1 555 001 0006',
relationship: 'Spouse',
),
],
newsletter: p.getBool(_kNewsletter) ?? false,
);
}
void updateName(String name) {
_prefs.setString(_kName, name);
state = state.copyWith(worker: state.worker.copyWith(name: name));
}
void toggleSetting(String value) {
final next = {...state.settings};
if (!next.remove(value)) next.add(value);
_prefs.setStringList(_kSettings, next.toList());
state = state.copyWith(settings: next);
}
void toggleSkill(String value) {
final next = {...state.skills};
if (!next.remove(value)) next.add(value);
_prefs.setStringList(_kSkills, next.toList());
state = state.copyWith(skills: next);
}
void addSkill(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
final next = {...state.skills, trimmed};
_prefs.setStringList(_kSkills, next.toList());
state = state.copyWith(skills: next);
}
void saveContact(EmergencyContact contact, {int? index}) {
final next = [...state.contacts];
if (index != null && index < next.length) {
next[index] = contact;
} else {
next.add(contact);
}
state = state.copyWith(contacts: next);
}
void removeContact(int index) {
final next = [...state.contacts]..removeAt(index);
state = state.copyWith(contacts: next);
}
void saveBank(BankAccount account) {
state = state.copyWith(bank: account);
}
void setNewsletter(bool value) {
_prefs.setBool(_kNewsletter, value);
state = state.copyWith(newsletter: value);
}
}
final profileProvider = NotifierProvider<ProfileController, ProfileState>(
ProfileController.new,
);
/// Live stats derived from the shift list, so the profile reflects what the
/// worker has actually done in this session.
final workerStatsProvider = Provider<Worker>((ref) {
final base = ref.watch(profileProvider).worker;
final shifts = ref.watch(shiftsProvider);
final completed = shifts
.where((s) => s.status == ShiftStatus.completed)
.toList();
final noShows = shifts.where((s) => s.status == ShiftStatus.noShow).length;
final cancels = shifts.where((s) => s.status == ShiftStatus.cancelled).length;
final onTime = completed
.where(
(s) =>
s.clockIn != null &&
!s.clockIn!.isAfter(s.start.add(const Duration(minutes: 5))),
)
.length;
return base.copyWith(
shiftsWorked: completed.length,
onTimePercent: completed.isEmpty
? 100
: (onTime * 100 / completed.length).round(),
noShows: noShows,
cancellations: cancels,
);
});

392
lib/state/seed.dart Normal file
View File

@@ -0,0 +1,392 @@
import '../models/compliance.dart';
import '../models/shift.dart';
import '../models/timecard.dart';
/// Demo content. Everything is anchored to "now" so the app is live whenever
/// it is opened: the next shift always starts in 41 minutes.
abstract final class Seed {
static DateTime get _now => DateTime.now();
static DateTime _at(int dayOffset, int hour, int minute) {
final base = _now.add(Duration(days: dayOffset));
return DateTime(base.year, base.month, base.day, hour, minute);
}
static const _baristaReqs = [
'Government ID',
'Food handler certification',
'Black pants',
'Black shirt',
'Non-slip black shoes',
];
static List<Shift> shifts() {
final now = _now;
// Inside the 30-minute clock-in window, so the app opens on a shift you
// can actually start.
final soon = now.add(const Duration(minutes: 20));
return [
// Today: the shift you are about to clock into.
Shift(
id: 's1',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: soon,
end: soon.add(const Duration(hours: 6)),
rate: 21.00,
status: ShiftStatus.booked,
distanceMiles: 2.4,
manager: 'Kirk R. - Owner',
requirements: _baristaReqs,
),
Shift(
id: 's2',
role: 'Server',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(1, 13, 0),
end: _at(1, 18, 0),
rate: 24.00,
status: ShiftStatus.booked,
distanceMiles: 3.1,
manager: 'Dana W. - Floor lead',
requirements: _baristaReqs,
),
// Open market, highest paying first.
Shift(
id: 's3',
role: 'Bartender',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(2, 16, 0),
end: _at(2, 23, 0),
rate: 28.00,
status: ShiftStatus.open,
distanceMiles: 3.1,
requirements: [..._baristaReqs, 'Responsible beverage service'],
),
Shift(
id: 's4',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: _at(3, 18, 0),
end: _at(3, 23, 50),
rate: 21.00,
status: ShiftStatus.open,
distanceMiles: 2.4,
requirements: _baristaReqs,
),
Shift(
id: 's5',
role: 'Host',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(4, 11, 0),
end: _at(4, 17, 0),
rate: 19.50,
status: ShiftStatus.open,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
Shift(
id: 's6',
role: 'Banquet server',
business: 'Legendary Events',
venue: 'Legendary Events - Convention Centre',
address: '150 W San Carlos St, San Jose CA',
start: _at(5, 9, 0),
end: _at(5, 17, 30),
rate: 22.50,
status: ShiftStatus.open,
distanceMiles: 4.8,
requirements: _baristaReqs,
),
// Worked history.
Shift(
id: 's7',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: _at(-4, 17, 50),
end: _at(-4, 23, 50),
rate: 21.00,
status: ShiftStatus.completed,
distanceMiles: 2.4,
clockIn: _at(-4, 18, 1),
clockOut: _at(-4, 23, 50),
requirements: _baristaReqs,
),
Shift(
id: 's8',
role: 'Barista',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(-6, 11, 0),
end: _at(-6, 18, 0),
rate: 21.00,
status: ShiftStatus.cancelled,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
Shift(
id: 's9',
role: 'Barista',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(-13, 9, 0),
end: _at(-13, 15, 0),
rate: 21.00,
status: ShiftStatus.noShow,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
];
}
static List<Timecard> timecards() {
final worked = shifts().firstWhere((s) => s.id == 's7');
return [
Timecard(
id: 'tc1',
shiftId: worked.id,
role: worked.role,
business: worked.business,
scheduledStart: worked.start,
scheduledEnd: worked.end,
clockIn: worked.clockIn!,
clockOut: worked.clockOut!,
breakMinutes: worked.breakMinutes,
rate: worked.rate,
status: TimecardStatus.needsReview,
submittedAt: worked.clockOut!.add(const Duration(minutes: 12)),
),
];
}
static List<ComplianceItem> documents() => [
ComplianceItem(
id: 'd1',
title: 'Government ID',
subtitle: 'State ID or passport',
state: ComplianceState.verified,
fileName: 'government-id-front.jpg',
fileMeta: '2.1 MB - uploaded 2 Aug',
expires: DateTime(2029, 3, 14),
),
ComplianceItem(
id: 'd2',
title: 'Social security',
subtitle: 'Card or SSA letter',
state: ComplianceState.verified,
fileName: 'ssn-card.jpg',
fileMeta: '1.2 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'd3',
title: 'Work permit',
subtitle: 'Right-to-work evidence',
state: ComplianceState.verified,
fileName: 'work-permit.pdf',
fileMeta: '640 KB - uploaded 2 Aug',
),
ComplianceItem(
id: 'd4',
title: 'Proof of address',
subtitle: 'Bill or statement, last 90 days',
state: ComplianceState.pending,
statusLabel: 'In review',
fileName: 'utility-bill.pdf',
fileMeta: '380 KB - uploaded yesterday',
),
];
static List<ComplianceItem> certificates() => [
ComplianceItem(
id: 'c1',
title: 'Food handler certification',
subtitle: 'Accepted by all California venues',
state: ComplianceState.verified,
statusLabel: 'Validated',
fileName: 'food-safety-certificate.pdf',
fileMeta: '840 KB - uploaded 2 Aug',
expires: DateTime(2027, 3, 14),
),
ComplianceItem(
id: 'c2',
title: 'Responsible beverage service',
subtitle: 'Needed for bartender shifts',
state: ComplianceState.verified,
statusLabel: 'Validated',
fileName: 'rbs-certificate.pdf',
fileMeta: '510 KB - uploaded 2 Aug',
expires: DateTime(2028, 1, 9),
),
ComplianceItem(
id: 'c3',
title: 'CPR / AED',
subtitle: 'Adds around 4 shifts a week near you',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
ComplianceItem(
id: 'c4',
title: 'Forklift licence',
subtitle: 'Warehouse and logistics roles',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
];
static List<ComplianceItem> taxForms() => [
ComplianceItem(
id: 't1',
title: 'Form I-9',
subtitle: 'Employment eligibility verification',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'i9-form.pdf',
fileMeta: 'Approved 3 Aug',
),
ComplianceItem(
id: 't2',
title: 'Form W-4',
subtitle: 'Sets how much tax is withheld',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'w4-form.pdf',
fileMeta: 'Approved 3 Aug',
),
];
static List<ComplianceItem> wardrobe() => [
ComplianceItem(
id: 'w1',
title: 'Black pants',
subtitle: 'Solid black dress pants or slacks',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-pants.jpg',
fileMeta: '1.4 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w2',
title: 'Black shirt',
subtitle: 'Plain, no logos',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-shirt.jpg',
fileMeta: '1.1 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w3',
title: 'Non-slip black shoes',
subtitle: 'Closed-toe, required on every shift',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-shoes.jpg',
fileMeta: '0.9 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w4',
title: 'Black belt',
subtitle: 'Plain, simple buckle',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
];
static const workSettings = <String>[
'Hospitality',
'Restaurant',
'Warehouse',
'Logistics',
'Events',
'Stadiums',
'Education',
'Healthcare & cafeteria',
'Convention centre',
'Catering',
];
static const skills = <String>[
'Food prep',
'Cooking',
'Grilling',
'Dishwashing',
'Food service',
'Banquet',
'Bartender',
'Barista',
'Customer service',
'Safe food handling',
'Event setup',
];
static const supportTopics = <String>[
'Shift issue',
'Pay or billing',
'Schedule clash',
'Account',
'Bug report',
'Feature idea',
];
static const faqs = <String, List<(String, String)>>{
'Getting started': [
(
'How do I finish my worker profile?',
'Open Profile, then Compliance. Work down the sections until every one '
'shows a green tick. You can accept shifts as soon as documents, '
'tax forms and attire are complete.',
),
(
"Why can't I apply to shifts yet?",
'Businesses only see workers who are fully compliant. Anything still '
'marked yellow on the compliance hub is blocking you.',
),
],
'Shifts and attendance': [
(
'How does clock-in work?',
'The clock-in button unlocks 30 minutes before your shift starts, once '
'you are within 150 m of the venue. Tap out when you leave and the '
'timecard submits itself.',
),
(
'What happens if I request a swap?',
'Your shift is offered to other qualified workers. You stay responsible '
'for it until someone accepts, so keep an eye on notifications.',
),
(
'What counts as a no-show?',
'Not clocking in within 30 minutes of the scheduled start, with no '
'cancellation. No-shows cost 15 points of trust index each.',
),
],
'Pay and compliance': [
(
'When do I actually get paid?',
'Semi-monthly. Each period closes on the 15th and the last day of the '
'month, and pays 11 days later.',
),
(
'How are documents verified?',
'A reviewer checks each upload by hand, usually within one business '
'day. You get a notification either way.',
),
],
};
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Injected in `main()` once SharedPreferences has loaded.
final prefsProvider = Provider<SharedPreferences>(
(ref) => throw UnimplementedError('prefsProvider must be overridden'),
);
class SessionState {
const SessionState({
this.onboardingSeen = false,
this.phone,
this.signedIn = false,
this.locationGranted = false,
this.notificationsGranted = false,
this.cameraGranted = false,
});
final bool onboardingSeen;
final String? phone;
final bool signedIn;
final bool locationGranted;
final bool notificationsGranted;
final bool cameraGranted;
/// The three-step permission run shown right after verification.
bool get permissionsDone =>
locationGranted && notificationsGranted && cameraGranted;
SessionState copyWith({
bool? onboardingSeen,
String? phone,
bool? signedIn,
bool? locationGranted,
bool? notificationsGranted,
bool? cameraGranted,
}) => SessionState(
onboardingSeen: onboardingSeen ?? this.onboardingSeen,
phone: phone ?? this.phone,
signedIn: signedIn ?? this.signedIn,
locationGranted: locationGranted ?? this.locationGranted,
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
cameraGranted: cameraGranted ?? this.cameraGranted,
);
}
class SessionController extends Notifier<SessionState> {
static const _kOnboarding = 'onboarding_seen';
static const _kPhone = 'phone';
static const _kSignedIn = 'signed_in';
static const _kLocation = 'perm_location';
static const _kNotifications = 'perm_notifications';
static const _kCamera = 'perm_camera';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
SessionState build() {
final p = _prefs;
return SessionState(
onboardingSeen: p.getBool(_kOnboarding) ?? false,
phone: p.getString(_kPhone),
signedIn: p.getBool(_kSignedIn) ?? false,
locationGranted: p.getBool(_kLocation) ?? false,
notificationsGranted: p.getBool(_kNotifications) ?? false,
cameraGranted: p.getBool(_kCamera) ?? false,
);
}
void completeOnboarding() {
_prefs.setBool(_kOnboarding, true);
state = state.copyWith(onboardingSeen: true);
}
/// The demo backend accepts any 10-digit number and always sends `123456`.
static const demoCode = '123456';
String sendCode(String phone) {
_prefs.setString(_kPhone, phone);
state = state.copyWith(phone: phone);
return demoCode;
}
bool verifyCode(String code) {
if (code != demoCode) return false;
_prefs.setBool(_kSignedIn, true);
state = state.copyWith(signedIn: true);
return true;
}
void grantLocation() {
_prefs.setBool(_kLocation, true);
state = state.copyWith(locationGranted: true);
}
void grantNotifications() {
_prefs.setBool(_kNotifications, true);
state = state.copyWith(notificationsGranted: true);
}
void grantCamera() {
_prefs.setBool(_kCamera, true);
state = state.copyWith(cameraGranted: true);
}
Future<void> signOut() async {
await _prefs.remove(_kSignedIn);
await _prefs.remove(_kPhone);
state = state.copyWith(signedIn: false, phone: '');
}
}
final sessionProvider = NotifierProvider<SessionController, SessionState>(
SessionController.new,
);

View File

@@ -0,0 +1,172 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/formatters.dart';
import '../models/shift.dart';
import '../models/timecard.dart';
import 'seed.dart';
import 'timecards_controller.dart';
class ShiftsController extends Notifier<List<Shift>> {
@override
List<Shift> build() => Seed.shifts();
Shift? byId(String id) {
for (final s in state) {
if (s.id == id) return s;
}
return null;
}
void _replace(Shift updated) {
state = [
for (final s in state)
if (s.id == updated.id) updated else s,
];
}
/// Accept an open shift. It moves straight into Booked.
void apply(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.open) return;
_replace(shift.copyWith(status: ShiftStatus.booked));
}
/// Give a booked shift back. Counts against the cancellation tally.
void cancel(String id) {
final shift = byId(id);
if (shift == null) return;
_replace(shift.copyWith(status: ShiftStatus.cancelled));
}
void clockIn(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.booked) return;
_replace(
shift.copyWith(status: ShiftStatus.active, clockIn: DateTime.now()),
);
}
/// Clocking out completes the shift and files the timecard automatically.
Timecard? clockOut(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.active) return null;
final now = DateTime.now();
final done = shift.copyWith(status: ShiftStatus.completed, clockOut: now);
_replace(done);
final card = Timecard(
id: 'tc-${shift.id}-${now.millisecondsSinceEpoch}',
shiftId: shift.id,
role: shift.role,
business: shift.business,
scheduledStart: shift.start,
scheduledEnd: shift.end,
clockIn: done.clockIn!,
clockOut: now,
breakMinutes: shift.breakMinutes,
rate: shift.rate,
status: TimecardStatus.needsReview,
submittedAt: now,
);
ref.read(timecardsProvider.notifier).add(card);
return card;
}
}
final shiftsProvider = NotifierProvider<ShiftsController, List<Shift>>(
ShiftsController.new,
);
final bookedShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where(
(s) =>
s.status == ShiftStatus.booked ||
s.status == ShiftStatus.active,
)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
return list;
});
final openShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.open)
.toList()
..sort((a, b) => b.rate.compareTo(a.rate));
return list;
});
final historyShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where(
(s) => const {
ShiftStatus.completed,
ShiftStatus.cancelled,
ShiftStatus.noShow,
}.contains(s.status),
)
.toList()
..sort((a, b) => b.start.compareTo(a.start));
return list;
});
/// The shift the Today screen leads with: whatever is running now, else the
/// next one on the calendar.
final nextShiftProvider = Provider<Shift?>((ref) {
final booked = ref.watch(bookedShiftsProvider);
for (final s in booked) {
if (s.status == ShiftStatus.active) return s;
}
final now = DateTime.now();
for (final s in booked) {
if (s.end.isAfter(now)) return s;
}
return booked.isEmpty ? null : booked.first;
});
final activeShiftProvider = Provider<Shift?>((ref) {
for (final s in ref.watch(shiftsProvider)) {
if (s.status == ShiftStatus.active) return s;
}
return null;
});
/// Gross pay, pre-tax, already banked from completed shifts.
final completedEarningsProvider = Provider<double>((ref) {
return ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.completed)
.fold<double>(0, (sum, s) => sum + s.pay);
});
final hoursLoggedProvider = Provider<Duration>((ref) {
return ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.completed)
.fold<Duration>(
Duration.zero,
(sum, s) => sum + (s.workedDuration ?? Duration.zero),
);
});
/// Booked but not yet worked: the money still coming.
final scheduledEarningsProvider = Provider<double>((ref) {
return ref
.watch(bookedShiftsProvider)
.fold<double>(0, (sum, s) => sum + s.scheduledPay);
});
/// Shifts falling on a given calendar day, used by the week strip.
final shiftsOnDayProvider = Provider.family<List<Shift>, DateTime>((ref, day) {
return ref
.watch(bookedShiftsProvider)
.where((s) => Fmt.sameDay(s.start, day))
.toList();
});

View File

@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/timecard.dart';
import 'seed.dart';
class TimecardsController extends Notifier<List<Timecard>> {
@override
List<Timecard> build() => Seed.timecards();
void add(Timecard card) {
state = [card, ...state];
}
void approve(String id) {
state = [
for (final t in state)
if (t.id == id) t.copyWith(status: TimecardStatus.approved) else t,
];
}
void dispute(String id, String note) {
state = [
for (final t in state)
if (t.id == id)
t.copyWith(status: TimecardStatus.disputed, disputeNote: note)
else
t,
];
}
Timecard? byId(String id) {
for (final t in state) {
if (t.id == id) return t;
}
return null;
}
}
final timecardsProvider = NotifierProvider<TimecardsController, List<Timecard>>(
TimecardsController.new,
);
/// Timecards still waiting on the worker to confirm or dispute.
final timecardsNeedingReviewProvider = Provider<List<Timecard>>((ref) {
return ref
.watch(timecardsProvider)
.where((t) => t.status == TimecardStatus.needsReview)
.toList();
});