Initial commit: Krow Worker App
This commit is contained in:
128
lib/state/availability_controller.dart
Normal file
128
lib/state/availability_controller.dart
Normal 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;
|
||||
});
|
||||
178
lib/state/compliance_controller.dart
Normal file
178
lib/state/compliance_controller.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
168
lib/state/profile_controller.dart
Normal file
168
lib/state/profile_controller.dart
Normal 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
392
lib/state/seed.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
115
lib/state/session_controller.dart
Normal file
115
lib/state/session_controller.dart
Normal 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,
|
||||
);
|
||||
172
lib/state/shifts_controller.dart
Normal file
172
lib/state/shifts_controller.dart
Normal 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();
|
||||
});
|
||||
49
lib/state/timecards_controller.dart
Normal file
49
lib/state/timecards_controller.dart
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user