feat: architecture overhaul, launchpad-style reports, and uber-style locations

- Strengthened Buffer Layer architecture to decouple Data Connect from Domain
- Rewired Coverage, Performance, and Forecast reports to match Launchpad logic
- Implemented Uber-style Preferred Locations search using Google Places API
- Added session recovery logic to prevent crashes on app restart
- Synchronized backend schemas & SDK for ShiftStatus enums
- Fixed various build/compilation errors and localization duplicates
This commit is contained in:
2026-02-20 17:20:06 +05:30
parent e6c4b51e84
commit 8849bf2273
60 changed files with 3804 additions and 2397 deletions

View File

@@ -1,371 +1,70 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import 'package:intl/intl.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import '../../domain/repositories/shifts_repository_interface.dart';
class ShiftsRepositoryImpl
implements ShiftsRepositoryInterface {
/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final dc.ShiftsConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance;
ShiftsRepositoryImpl({
dc.ShiftsConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getShiftsRepository(),
_service = service ?? dc.DataConnectService.instance;
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
final Map<String, String> _shiftToAppIdMap = {};
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
final Map<String, String> _appToRoleIdMap = {};
// This need to be an APPLICATION
// THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs.
@override
Future<List<Shift>> getMyShifts({
required DateTime start,
required DateTime end,
}) async {
return _fetchApplications(start: start, end: end);
final staffId = await _service.getStaffId();
return _connectorRepository.getMyShifts(
staffId: staffId,
start: start,
end: end,
);
}
@override
Future<List<Shift>> getPendingAssignments() async {
return <Shift>[];
final staffId = await _service.getStaffId();
return _connectorRepository.getPendingAssignments(staffId: staffId);
}
@override
Future<List<Shift>> getCancelledShifts() async {
return <Shift>[];
final staffId = await _service.getStaffId();
return _connectorRepository.getCancelledShifts(staffId: staffId);
}
@override
Future<List<Shift>> getHistoryShifts() async {
final staffId = await _service.getStaffId();
final fdc.QueryResult<dc.ListCompletedApplicationsByStaffIdData, dc.ListCompletedApplicationsByStaffIdVariables> response = await _service.executeProtected(() => _service.connector
.listCompletedApplicationsByStaffId(staffId: staffId)
.execute());
final List<Shift> shifts = [];
for (final app in response.data.applications) {
_shiftToAppIdMap[app.shift.id] = app.id;
_appToRoleIdMap[app.id] = app.shiftRole.id;
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
final DateTime? createdDt = _service.toDateTime(app.createdAt);
shifts.add(
Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: _mapStatus(dc.ApplicationStatus.CHECKED_OUT),
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
),
);
}
return shifts;
}
Future<List<Shift>> _fetchApplications({
DateTime? start,
DateTime? end,
}) async {
final staffId = await _service.getStaffId();
var query = _service.connector.getApplicationsByStaffId(staffId: staffId);
if (start != null && end != null) {
query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end));
}
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> response = await _service.executeProtected(() => query.execute());
final apps = response.data.applications;
final List<Shift> shifts = [];
for (final app in apps) {
_shiftToAppIdMap[app.shift.id] = app.id;
_appToRoleIdMap[app.id] = app.shiftRole.id;
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
final DateTime? createdDt = _service.toDateTime(app.createdAt);
// Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED)
final bool hasCheckIn = app.checkInTime != null;
final bool hasCheckOut = app.checkOutTime != null;
dc.ApplicationStatus? appStatus;
if (app.status is dc.Known<dc.ApplicationStatus>) {
appStatus = (app.status as dc.Known<dc.ApplicationStatus>).value;
}
final String mappedStatus = hasCheckOut
? 'completed'
: hasCheckIn
? 'checked_in'
: _mapStatus(appStatus ?? dc.ApplicationStatus.CONFIRMED);
shifts.add(
Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: mappedStatus,
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
),
);
}
return shifts;
}
String _mapStatus(dc.ApplicationStatus status) {
switch (status) {
case dc.ApplicationStatus.CONFIRMED:
return 'confirmed';
case dc.ApplicationStatus.PENDING:
return 'pending';
case dc.ApplicationStatus.CHECKED_OUT:
return 'completed';
case dc.ApplicationStatus.REJECTED:
return 'cancelled';
default:
return 'open';
}
return _connectorRepository.getHistoryShifts(staffId: staffId);
}
@override
Future<List<Shift>> getAvailableShifts(String query, String type) async {
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
if (vendorId == null || vendorId.isEmpty) {
return <Shift>[];
}
final fdc.QueryResult<dc.ListShiftRolesByVendorIdData, dc.ListShiftRolesByVendorIdVariables> result = await _service.executeProtected(() => _service.connector
.listShiftRolesByVendorId(vendorId: vendorId)
.execute());
final allShiftRoles = result.data.shiftRoles;
// Fetch my applications to filter out already booked shifts
final List<Shift> myShifts = await _fetchApplications();
final Set<String> myShiftIds = myShifts.map((s) => s.id).toSet();
final List<Shift> mappedShifts = [];
for (final sr in allShiftRoles) {
// Skip if I have already applied/booked this shift
if (myShiftIds.contains(sr.shiftId)) continue;
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
final startDt = _service.toDateTime(sr.startTime);
final endDt = _service.toDateTime(sr.endTime);
final createdDt = _service.toDateTime(sr.createdAt);
mappedShifts.add(
Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.role.name,
clientName: sr.shift.order.business.businessName,
logoUrl: null,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? '',
locationAddress: sr.shift.locationAddress ?? '',
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null
? DateFormat('HH:mm').format(startDt)
: '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
description: sr.shift.description,
durationDays: sr.shift.durationDays,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
),
);
}
if (query.isNotEmpty) {
return mappedShifts
.where(
(s) =>
s.title.toLowerCase().contains(query.toLowerCase()) ||
s.clientName.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}
return mappedShifts;
final staffId = await _service.getStaffId();
return _connectorRepository.getAvailableShifts(
staffId: staffId,
query: query,
type: type,
);
}
@override
Future<Shift?> getShiftDetails(String shiftId, {String? roleId}) async {
return _getShiftDetails(shiftId, roleId: roleId);
}
Future<Shift?> _getShiftDetails(String shiftId, {String? roleId}) async {
if (roleId != null && roleId.isNotEmpty) {
final roleResult = await _service.executeProtected(() => _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
.execute());
final sr = roleResult.data.shiftRole;
if (sr == null) return null;
final DateTime? startDt = _service.toDateTime(sr.startTime);
final DateTime? endDt = _service.toDateTime(sr.endTime);
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
final String staffId = await _service.getStaffId();
bool hasApplied = false;
String status = 'open';
final apps = await _service.executeProtected(() =>
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
final app = apps.data.applications
.where(
(a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId,
)
.firstOrNull;
if (app != null) {
hasApplied = true;
if (app.status is dc.Known<dc.ApplicationStatus>) {
final dc.ApplicationStatus s =
(app.status as dc.Known<dc.ApplicationStatus>).value;
status = _mapStatus(s);
}
}
return Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.shift.order.business.businessName,
clientName: sr.shift.order.business.businessName,
logoUrl: sr.shift.order.business.companyLogoUrl,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
locationAddress: sr.shift.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: status,
description: sr.shift.description,
durationDays: null,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
hasApplied: hasApplied,
totalValue: sr.totalValue,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
);
}
final fdc.QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
final s = result.data.shift;
if (s == null) return null;
int? required;
int? filled;
Break? breakInfo;
try {
final rolesRes = await _service.executeProtected(() =>
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
if (rolesRes.data.shiftRoles.isNotEmpty) {
required = 0;
filled = 0;
for (var r in rolesRes.data.shiftRoles) {
required = (required ?? 0) + r.count;
filled = (filled ?? 0) + (r.assigned ?? 0);
}
// Use the first role's break info as a representative
final firstRole = rolesRes.data.shiftRoles.first;
breakInfo = BreakAdapter.fromData(
isPaid: firstRole.isBreakPaid ?? false,
breakTime: firstRole.breakType?.stringValue,
);
}
} catch (_) {}
final startDt = _service.toDateTime(s.startTime);
final endDt = _service.toDateTime(s.endTime);
final createdDt = _service.toDateTime(s.createdAt);
return Shift(
id: s.id,
title: s.title,
clientName: s.order.business.businessName,
logoUrl: null,
hourlyRate: s.cost ?? 0.0,
location: s.location ?? '',
locationAddress: s.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN',
description: s.description,
durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
latitude: s.latitude,
longitude: s.longitude,
breakInfo: breakInfo,
final staffId = await _service.getStaffId();
return _connectorRepository.getShiftDetails(
shiftId: shiftId,
staffId: staffId,
roleId: roleId,
);
}
@@ -376,182 +75,29 @@ class ShiftsRepositoryImpl
String? roleId,
}) async {
final staffId = await _service.getStaffId();
String targetRoleId = roleId ?? '';
if (targetRoleId.isEmpty) {
throw Exception('Missing role id.');
}
final roleResult = await _service.executeProtected(() => _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
.execute());
final role = roleResult.data.shiftRole;
if (role == null) {
throw Exception('Shift role not found');
}
final shiftResult =
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
final shift = shiftResult.data.shift;
if (shift == null) {
throw Exception('Shift not found');
}
final DateTime? shiftDate = _service.toDateTime(shift.date);
if (shiftDate != null) {
final DateTime dayStartUtc = DateTime.utc(
shiftDate.year,
shiftDate.month,
shiftDate.day,
);
final DateTime dayEndUtc = DateTime.utc(
shiftDate.year,
shiftDate.month,
shiftDate.day,
23,
59,
59,
999,
999,
);
final dayApplications = await _service.executeProtected(() => _service.connector
.vaidateDayStaffApplication(staffId: staffId)
.dayStart(_service.toTimestamp(dayStartUtc))
.dayEnd(_service.toTimestamp(dayEndUtc))
.execute());
if (dayApplications.data.applications.isNotEmpty) {
throw Exception('The user already has a shift that day.');
}
}
final existingApplicationResult = await _service.executeProtected(() => _service.connector
.getApplicationByStaffShiftAndRole(
staffId: staffId,
shiftId: shiftId,
roleId: targetRoleId,
)
.execute());
if (existingApplicationResult.data.applications.isNotEmpty) {
throw Exception('Application already exists.');
}
final int assigned = role.assigned ?? 0;
if (assigned >= role.count) {
throw Exception('This shift is full.');
}
final int filled = shift.filled ?? 0;
String? appId;
bool updatedRole = false;
bool updatedShift = false;
try {
final appResult = await _service.executeProtected(() => _service.connector
.createApplication(
shiftId: shiftId,
staffId: staffId,
roleId: targetRoleId,
status: dc.ApplicationStatus.CONFIRMED,
origin: dc.ApplicationOrigin.STAFF,
)
// TODO: this should be PENDING so a vendor can accept it.
.execute());
appId = appResult.data.application_insert.id;
await _service.executeProtected(() => _service.connector
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
.assigned(assigned + 1)
.execute());
updatedRole = true;
await _service.executeProtected(
() => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute());
updatedShift = true;
} catch (e) {
if (updatedShift) {
try {
await _service.connector.updateShift(id: shiftId).filled(filled).execute();
} catch (_) {}
}
if (updatedRole) {
try {
await _service.connector
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
.assigned(assigned)
.execute();
} catch (_) {}
}
if (appId != null) {
try {
await _service.connector.deleteApplication(id: appId).execute();
} catch (_) {}
}
rethrow;
}
return _connectorRepository.applyForShift(
shiftId: shiftId,
staffId: staffId,
isInstantBook: isInstantBook,
roleId: roleId,
);
}
@override
Future<void> acceptShift(String shiftId) async {
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED);
final staffId = await _service.getStaffId();
return _connectorRepository.acceptShift(
shiftId: shiftId,
staffId: staffId,
);
}
@override
Future<void> declineShift(String shiftId) async {
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED);
}
Future<void> _updateApplicationStatus(
String shiftId,
dc.ApplicationStatus newStatus,
) async {
String? appId = _shiftToAppIdMap[shiftId];
String? roleId;
if (appId == null) {
// Try to find it in pending
await getPendingAssignments();
}
// Re-check map
appId = _shiftToAppIdMap[shiftId];
if (appId != null) {
roleId = _appToRoleIdMap[appId];
} else {
// Fallback fetch
final staffId = await _service.getStaffId();
final apps = await _service.executeProtected(() =>
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
final app = apps.data.applications
.where((a) => a.shiftId == shiftId)
.firstOrNull;
if (app != null) {
appId = app.id;
roleId = app.shiftRole.id;
}
}
if (appId == null || roleId == null) {
// If we are rejecting and can't find an application, create one as rejected (declining an available shift)
if (newStatus == dc.ApplicationStatus.REJECTED) {
final rolesResult = await _service.executeProtected(() =>
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
if (rolesResult.data.shiftRoles.isNotEmpty) {
final role = rolesResult.data.shiftRoles.first;
final staffId = await _service.getStaffId();
await _service.executeProtected(() => _service.connector
.createApplication(
shiftId: shiftId,
staffId: staffId,
roleId: role.id,
status: dc.ApplicationStatus.REJECTED,
origin: dc.ApplicationOrigin.STAFF,
)
.execute());
return;
}
}
throw Exception("Application not found for shift $shiftId");
}
await _service.executeProtected(() => _service.connector
.updateApplicationStatus(id: appId!)
.status(newStatus)
.execute());
final staffId = await _service.getStaffId();
return _connectorRepository.declineShift(
shiftId: shiftId,
staffId: staffId,
);
}
}