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,261 +1,58 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as data_connect;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/billing_period.dart';
import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] in the Data layer.
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
///
/// This class is responsible for retrieving billing data from the
/// Data Connect layer and mapping it to Domain entities.
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
final dc.BillingConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
BillingRepositoryImpl({
data_connect.DataConnectService? service,
}) : _service = service ?? data_connect.DataConnectService.instance;
dc.BillingConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getBillingRepository(),
_service = service ?? dc.DataConnectService.instance;
final data_connect.DataConnectService _service;
/// Fetches bank accounts associated with the business.
@override
Future<List<BusinessBankAccount>> getBankAccounts() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<
data_connect.GetAccountsByOwnerIdData,
data_connect.GetAccountsByOwnerIdVariables> result =
await _service.connector
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data.accounts.map(_mapBankAccount).toList();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getBankAccounts(businessId: businessId);
}
/// Fetches the current bill amount by aggregating open invoices.
@override
Future<double> getCurrentBillAmount() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
}
/// Fetches the history of paid invoices.
@override
Future<List<Invoice>> getInvoiceHistory() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(
businessId: businessId,
)
.limit(10)
.execute();
return result.data.invoices.map(_mapInvoice).toList();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getInvoiceHistory(businessId: businessId);
}
/// Fetches pending invoices (Open or Disputed).
@override
Future<List<Invoice>> getPendingInvoices() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where(
(Invoice i) =>
i.status == InvoiceStatus.open ||
i.status == InvoiceStatus.disputed,
)
.toList();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getPendingInvoices(businessId: businessId);
}
/// Fetches the estimated savings amount.
@override
Future<double> getSavingsAmount() async {
// Simulating savings calculation (e.g., comparing to market rates).
await Future<void>.delayed(const Duration(milliseconds: 0));
// Simulating savings calculation
return 0.0;
}
/// Fetches the breakdown of spending.
@override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final DateTime start;
final DateTime end;
if (period == BillingPeriod.week) {
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
start = DateTime(monday.year, monday.month, monday.day);
end = DateTime(
monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
} else {
start = DateTime(now.year, now.month, 1);
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
}
final fdc.QueryResult<
data_connect.ListShiftRolesByBusinessAndDatesSummaryData,
data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables>
result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) {
return <InvoiceItem>[];
}
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final data_connect
.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId;
final String roleName = role.role.name;
final double hours = role.hours ?? 0.0;
final double totalValue = role.totalValue ?? 0.0;
final _RoleSummary? existing = summary[roleId];
if (existing == null) {
summary[roleId] = _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: hours,
totalValue: totalValue,
);
} else {
summary[roleId] = existing.copyWith(
totalHours: existing.totalHours + hours,
totalValue: existing.totalValue + totalValue,
);
}
}
return summary.values
.map(
(_RoleSummary item) => InvoiceItem(
id: item.roleId,
invoiceId: item.roleId,
staffId: item.roleName,
workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue,
),
)
.toList();
});
}
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
return Invoice(
id: invoice.id,
eventId: invoice.orderId,
businessId: invoice.businessId,
status: _mapInvoiceStatus(invoice.status),
totalAmount: invoice.amount,
workAmount: invoice.amount,
addonsAmount: invoice.otherCharges ?? 0,
invoiceNumber: invoice.invoiceNumber,
issueDate: _service.toDateTime(invoice.issueDate)!,
);
}
BusinessBankAccount _mapBankAccount(
data_connect.GetAccountsByOwnerIdAccounts account,
) {
return BusinessBankAccountAdapter.fromPrimitives(
id: account.id,
bank: account.bank,
last4: account.last4,
isPrimary: account.isPrimary ?? false,
expiryTime: _service.toDateTime(account.expiryTime),
);
}
InvoiceStatus _mapInvoiceStatus(
data_connect.EnumValue<data_connect.InvoiceStatus> status,
) {
if (status is data_connect.Known<data_connect.InvoiceStatus>) {
switch (status.value) {
case data_connect.InvoiceStatus.PAID:
return InvoiceStatus.paid;
case data_connect.InvoiceStatus.OVERDUE:
return InvoiceStatus.overdue;
case data_connect.InvoiceStatus.DISPUTED:
return InvoiceStatus.disputed;
case data_connect.InvoiceStatus.APPROVED:
return InvoiceStatus.verified;
case data_connect.InvoiceStatus.PENDING_REVIEW:
case data_connect.InvoiceStatus.PENDING:
case data_connect.InvoiceStatus.DRAFT:
return InvoiceStatus.open;
}
}
return InvoiceStatus.open;
}
}
class _RoleSummary {
const _RoleSummary({
required this.roleId,
required this.roleName,
required this.totalHours,
required this.totalValue,
});
final String roleId;
final String roleName;
final double totalHours;
final double totalValue;
_RoleSummary copyWith({
double? totalHours,
double? totalValue,
}) {
return _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: totalHours ?? this.totalHours,
totalValue: totalValue ?? this.totalValue,
final businessId = await _service.getBusinessId();
return _connectorRepository.getSpendingBreakdown(
businessId: businessId,
period: period,
);
}
}

View File

@@ -1,4 +0,0 @@
enum BillingPeriod {
week,
month,
}

View File

@@ -1,5 +1,4 @@
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_period.dart';
/// Repository interface for billing related operations.
///

View File

@@ -1,6 +1,5 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_period.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the spending breakdown items.

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../domain/models/billing_period.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base class for all billing events.
abstract class BillingEvent extends Equatable {

View File

@@ -1,6 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/billing_period.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';

View File

@@ -2,7 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/models/billing_period.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../blocs/billing_event.dart';

View File

@@ -1,68 +1,35 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_repository.dart';
/// Implementation of [CoverageRepository] in the Data layer.
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
///
/// This class provides mock data for the coverage feature.
/// In a production environment, this would delegate to `packages/data_connect`
/// for real data access (e.g., Firebase Data Connect, REST API).
///
/// It strictly adheres to the Clean Architecture data layer responsibilities:
/// - No business logic (except necessary data transformation).
/// - Delegates to data sources (currently mock data, will be `data_connect`).
/// - Returns domain entities from `domain/ui_entities`.
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service;
final dc.CoverageConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// Fetches shifts for a specific date.
CoverageRepositoryImpl({
dc.CoverageConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getCoverageRepository(),
_service = service ?? dc.DataConnectService.instance;
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
await _service.connector
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _service.toTimestamp(start),
dayEnd: _service.toTimestamp(end),
)
.execute();
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getShiftsForDate(
businessId: businessId,
date: date,
);
}
/// Fetches coverage statistics for a specific date.
@override
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
// Get shifts for the date
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
// Calculate statistics
final int totalNeeded = shifts.fold<int>(
0,
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
@@ -90,129 +57,4 @@ class CoverageRepositoryImpl implements CoverageRepository {
late: late,
);
}
List<CoverageShift> _mapCoverageShifts(
List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
DateTime date,
) {
if (shiftRoles.isEmpty && applications.isEmpty) {
return <CoverageShift>[];
}
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in shiftRoles) {
final String key = '${shiftRole.shiftId}:${shiftRole.roleId}';
groups[key] = _CoverageGroup(
shiftId: shiftRole.shiftId,
roleId: shiftRole.roleId,
title: shiftRole.role.name,
location: shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '',
startTime: _formatTime(shiftRole.startTime) ?? '00:00',
workersNeeded: shiftRole.count,
date: shiftRole.shift.date?.toDateTime() ?? date,
workers: <CoverageWorker>[],
);
}
for (final dc.ListStaffsApplicationsByBusinessForDayApplications app
in applications) {
final String key = '${app.shiftId}:${app.roleId}';
final _CoverageGroup existing = groups[key] ??
_CoverageGroup(
shiftId: app.shiftId,
roleId: app.roleId,
title: app.shiftRole.role.name,
location: app.shiftRole.shift.location ??
app.shiftRole.shift.locationAddress ??
'',
startTime: _formatTime(app.shiftRole.startTime) ?? '00:00',
workersNeeded: app.shiftRole.count,
date: app.shiftRole.shift.date?.toDateTime() ?? date,
workers: <CoverageWorker>[],
);
existing.workers.add(
CoverageWorker(
name: app.staff.fullName,
status: _mapWorkerStatus(app.status),
checkInTime: _formatTime(app.checkInTime),
),
);
groups[key] = existing;
}
return groups.values
.map(
(_CoverageGroup group) => CoverageShift(
id: '${group.shiftId}:${group.roleId}',
title: group.title,
location: group.location,
startTime: group.startTime,
workersNeeded: group.workersNeeded,
date: group.date,
workers: group.workers,
),
)
.toList();
}
CoverageWorkerStatus _mapWorkerStatus(
dc.EnumValue<dc.ApplicationStatus> status,
) {
if (status is dc.Known<dc.ApplicationStatus>) {
switch (status.value) {
case dc.ApplicationStatus.PENDING:
return CoverageWorkerStatus.pending;
case dc.ApplicationStatus.REJECTED:
return CoverageWorkerStatus.rejected;
case dc.ApplicationStatus.CONFIRMED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.CHECKED_IN:
return CoverageWorkerStatus.checkedIn;
case dc.ApplicationStatus.CHECKED_OUT:
return CoverageWorkerStatus.checkedOut;
case dc.ApplicationStatus.LATE:
return CoverageWorkerStatus.late;
case dc.ApplicationStatus.NO_SHOW:
return CoverageWorkerStatus.noShow;
case dc.ApplicationStatus.COMPLETED:
return CoverageWorkerStatus.completed;
}
}
return CoverageWorkerStatus.pending;
}
String? _formatTime(fdc.Timestamp? timestamp) {
if (timestamp == null) {
return null;
}
final DateTime date = timestamp.toDateTime().toLocal();
final String hour = date.hour.toString().padLeft(2, '0');
final String minute = date.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
}
class _CoverageGroup {
_CoverageGroup({
required this.shiftId,
required this.roleId,
required this.title,
required this.location,
required this.startTime,
required this.workersNeeded,
required this.date,
required this.workers,
});
final String shiftId;
final String roleId;
final String title;
final String location;
final String startTime;
final int workersNeeded;
final DateTime date;
final List<CoverageWorker> workers;
}

View File

@@ -1,119 +1,26 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/home_repository_interface.dart';
/// Implementation of [HomeRepositoryInterface] that delegates to [HomeRepositoryMock].
/// Implementation of [HomeRepositoryInterface] that delegates to [dc.HomeConnectorRepository].
///
/// This implementation resides in the data layer and acts as a bridge between the
/// domain layer and the data source (in this case, a mock from data_connect).
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class HomeRepositoryImpl implements HomeRepositoryInterface {
/// Creates a [HomeRepositoryImpl].
HomeRepositoryImpl(this._service);
final dc.HomeConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
HomeRepositoryImpl({
dc.HomeConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getHomeRepository(),
_service = service ?? dc.DataConnectService.instance;
@override
Future<HomeDashboardData> getDashboardData() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = DateTime(
monday.year,
monday.month,
monday.day,
);
final DateTime weekRangeEnd = DateTime(
monday.year,
monday.month,
monday.day + 13,
23,
59,
59,
999,
);
final fdc.QueryResult<
dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables
>
completedResult = await _service.connector
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _service.toTimestamp(weekRangeStart),
dateTo: _service.toTimestamp(weekRangeEnd),
)
.execute();
double weeklySpending = 0.0;
double next7DaysSpending = 0.0;
int weeklyShifts = 0;
int next7DaysScheduled = 0;
for (final dc.GetCompletedShiftsByBusinessIdShifts shift
in completedResult.data.shifts) {
final DateTime? shiftDate = shift.date?.toDateTime();
if (shiftDate == null) {
continue;
}
final int offset = shiftDate.difference(weekRangeStart).inDays;
if (offset < 0 || offset > 13) {
continue;
}
final double cost = shift.cost ?? 0.0;
if (offset <= 6) {
weeklySpending += cost;
weeklyShifts += 1;
} else {
next7DaysSpending += cost;
next7DaysScheduled += 1;
}
}
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = DateTime(
now.year,
now.month,
now.day,
23,
59,
59,
999,
);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
int totalNeeded = 0;
int totalFilled = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in result.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalFilled += shiftRole.assigned ?? 0;
}
return HomeDashboardData(
weeklySpending: weeklySpending,
next7DaysSpending: next7DaysSpending,
weeklyShifts: weeklyShifts,
next7DaysScheduled: next7DaysScheduled,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
);
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getDashboardData(businessId: businessId);
}
@override
@@ -121,7 +28,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? business = session?.business;
// If session data is available, return it immediately
if (business != null) {
return UserSessionData(
businessName: business.businessName,
@@ -130,74 +36,38 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
}
return await _service.run(() async {
// If session is not initialized, attempt to fetch business data to populate session
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
businessResult = await _service.connector
final businessResult = await _service.connector
.getBusinessById(id: businessId)
.execute();
if (businessResult.data.business == null) {
final b = businessResult.data.business;
if (b == null) {
throw Exception('Business data not found for ID: $businessId');
}
final dc.ClientSession updatedSession = dc.ClientSession(
final updatedSession = dc.ClientSession(
business: dc.ClientBusinessSession(
id: businessResult.data.business!.id,
businessName: businessResult.data.business?.businessName ?? '',
email: businessResult.data.business?.email ?? '',
city: businessResult.data.business?.city ?? '',
contactName: businessResult.data.business?.contactName ?? '',
companyLogoUrl: businessResult.data.business?.companyLogoUrl,
id: b.id,
businessName: b.businessName,
email: b.email ?? '',
city: b.city ?? '',
contactName: b.contactName ?? '',
companyLogoUrl: b.companyLogoUrl,
),
);
dc.ClientSessionStore.instance.setSession(updatedSession);
return UserSessionData(
businessName: businessResult.data.business!.businessName,
photoUrl: businessResult.data.business!.companyLogoUrl,
businessName: b.businessName,
photoUrl: b.companyLogoUrl,
);
});
}
@override
Future<List<ReorderItem>> getRecentReorders() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
>
result = await _service.connector
.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
)
.execute();
return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) {
final String location =
shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem(
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
location: location,
hourlyRate: shiftRole.role.costPerHour,
hours: shiftRole.hours ?? 0,
workers: shiftRole.count,
type: type,
);
}).toList();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.getRecentReorders(businessId: businessId);
}
}

View File

@@ -1,38 +1,30 @@
import 'dart:convert';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart'
show
HubHasOrdersException,
BusinessNotFoundException,
NotAuthenticatedException;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class HubRepositoryImpl implements HubRepositoryInterface {
HubRepositoryImpl({required dc.DataConnectService service})
: _service = service;
final dc.HubsConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
HubRepositoryImpl({
dc.HubsConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getHubsRepository(),
_service = service ?? dc.DataConnectService.instance;
@override
Future<List<domain.Hub>> getHubs() async {
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
});
Future<List<Hub>> getHubs() async {
final businessId = await _service.getBusinessId();
return _connectorRepository.getHubs(businessId: businessId);
}
@override
Future<domain.Hub> createHub({
Future<Hub> createHub({
required String name,
required String address,
String? placeId,
@@ -44,77 +36,26 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? country,
String? zipCode,
}) async {
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final String? cityValue = city ?? placeAddress?.city ?? business.city;
final String? stateValue = state ?? placeAddress?.state;
final String? streetValue = street ?? placeAddress?.street;
final String? countryValue = country ?? placeAddress?.country;
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
result = await _service.connector
.createTeamHub(teamId: teamId, hubName: name, address: address)
.placeId(placeId)
.latitude(latitude)
.longitude(longitude)
.city(cityValue?.isNotEmpty == true ? cityValue : '')
.state(stateValue)
.street(streetValue)
.country(countryValue)
.zipCode(zipCodeValue)
.execute();
final String createdId = result.data.teamHub_insert.id;
final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId,
businessId: business.id,
);
domain.Hub? createdHub;
for (final domain.Hub hub in hubs) {
if (hub.id == createdId) {
createdHub = hub;
break;
}
}
return createdHub ??
domain.Hub(
id: createdId,
businessId: business.id,
name: name,
address: address,
nfcTagId: null,
status: domain.HubStatus.active,
);
});
final businessId = await _service.getBusinessId();
return _connectorRepository.createHub(
businessId: businessId,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
);
}
@override
Future<void> deleteHub(String id) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<
dc.ListOrdersByBusinessAndTeamHubData,
dc.ListOrdersByBusinessAndTeamHubVariables
>
result = await _service.connector
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
.execute();
if (result.data.orders.isNotEmpty) {
throw HubHasOrdersException(
technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
);
}
await _service.connector.deleteTeamHub(id: id).execute();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.deleteHub(businessId: businessId, id: id);
}
@override
@@ -125,7 +66,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
}
@override
Future<domain.Hub> updateHub({
Future<Hub> updateHub({
required String id,
String? name,
String? address,
@@ -138,283 +79,20 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? country,
String? zipCode,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress =
placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector
.updateTeamHub(id: id);
if (name != null) builder.hubName(name);
if (address != null) builder.address(address);
if (placeId != null || placeAddress != null) {
builder.placeId(placeId ?? placeAddress?.street);
}
if (latitude != null) builder.latitude(latitude);
if (longitude != null) builder.longitude(longitude);
if (city != null || placeAddress?.city != null) {
builder.city(city ?? placeAddress?.city);
}
if (state != null || placeAddress?.state != null) {
builder.state(state ?? placeAddress?.state);
}
if (street != null || placeAddress?.street != null) {
builder.street(street ?? placeAddress?.street);
}
if (country != null || placeAddress?.country != null) {
builder.country(country ?? placeAddress?.country);
}
if (zipCode != null || placeAddress?.zipCode != null) {
builder.zipCode(zipCode ?? placeAddress?.zipCode);
}
await builder.execute();
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId,
businessId: business.id,
);
for (final domain.Hub hub in hubs) {
if (hub.id == id) return hub;
}
// Fallback: return a reconstructed Hub from the update inputs.
return domain.Hub(
id: id,
businessId: business.id,
name: name ?? '',
address: address ?? '',
nfcTagId: null,
status: domain.HubStatus.active,
);
});
}
Future<dc.GetBusinessesByUserIdBusinesses>
_getBusinessForCurrentUser() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? cachedBusiness = session?.business;
if (cachedBusiness != null) {
return dc.GetBusinessesByUserIdBusinesses(
id: cachedBusiness.id,
businessName: cachedBusiness.businessName,
userId: _service.auth.currentUser?.uid ?? '',
rateGroup: const dc.Known<dc.BusinessRateGroup>(
dc.BusinessRateGroup.STANDARD,
),
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
contactName: cachedBusiness.contactName,
companyLogoUrl: cachedBusiness.companyLogoUrl,
phone: null,
email: cachedBusiness.email,
hubBuilding: null,
address: null,
city: cachedBusiness.city,
area: null,
sector: null,
notes: null,
createdAt: null,
updatedAt: null,
);
}
final firebase.User? user = _service.auth.currentUser;
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'No Firebase user in currentUser',
);
}
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
result = await _service.connector
.getBusinessesByUserId(userId: user.uid)
.execute();
if (result.data.businesses.isEmpty) {
await _service.auth.signOut();
throw BusinessNotFoundException(
technicalMessage: 'No business found for user ${user.uid}',
);
}
final dc.GetBusinessesByUserIdBusinesses business =
result.data.businesses.first;
if (session != null) {
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
email: business.email,
city: business.city,
contactName: business.contactName,
companyLogoUrl: business.companyLogoUrl,
),
),
);
}
return business;
}
Future<String> _getOrCreateTeamId(
dc.GetBusinessesByUserIdBusinesses business,
) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
teamsResult = await _service.connector
.getTeamsByOwnerId(ownerId: business.id)
.execute();
if (teamsResult.data.teams.isNotEmpty) {
return teamsResult.data.teams.first.id;
}
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
.createTeam(
teamName: '${business.businessName} Team',
ownerId: business.id,
ownerName: business.contactName ?? '',
ownerRole: 'OWNER',
);
if (business.email != null) {
createTeamBuilder.email(business.email);
}
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
createTeamResult = await createTeamBuilder.execute();
final String teamId = createTeamResult.data.team_insert.id;
return teamId;
}
Future<List<domain.Hub>> _fetchHubsForTeam({
required String teamId,
required String businessId,
}) async {
final QueryResult<
dc.GetTeamHubsByTeamIdData,
dc.GetTeamHubsByTeamIdVariables
>
hubsResult = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
return hubsResult.data.teamHubs
.map(
(dc.GetTeamHubsByTeamIdTeamHubs hub) => domain.Hub(
id: hub.id,
businessId: businessId,
name: hub.hubName,
address: hub.address,
nfcTagId: null,
status: hub.isActive
? domain.HubStatus.active
: domain.HubStatus.inactive,
),
)
.toList();
}
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
final Uri uri = Uri.https(
'maps.googleapis.com',
'/maps/api/place/details/json',
<String, String>{
'place_id': placeId,
'fields': 'address_component',
'key': AppConfig.googleMapsApiKey,
},
final businessId = await _service.getBusinessId();
return _connectorRepository.updateHub(
businessId: businessId,
id: id,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
);
try {
final http.Response response = await http.get(uri);
if (response.statusCode != 200) {
return null;
}
final Map<String, dynamic> payload =
json.decode(response.body) as Map<String, dynamic>;
if (payload['status'] != 'OK') {
return null;
}
final Map<String, dynamic>? result =
payload['result'] as Map<String, dynamic>?;
final List<dynamic>? components =
result?['address_components'] as List<dynamic>?;
if (components == null || components.isEmpty) {
return null;
}
String? streetNumber;
String? route;
String? city;
String? state;
String? country;
String? zipCode;
for (final dynamic entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types =
component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?;
if (types.contains('street_number')) {
streetNumber = longName;
} else if (types.contains('route')) {
route = longName;
} else if (types.contains('locality')) {
city = longName;
} else if (types.contains('postal_town')) {
city ??= longName;
} else if (types.contains('administrative_area_level_2')) {
city ??= longName;
} else if (types.contains('administrative_area_level_1')) {
state = shortName ?? longName;
} else if (types.contains('country')) {
country = shortName ?? longName;
} else if (types.contains('postal_code')) {
zipCode = longName;
}
}
final String streetValue = <String?>[streetNumber, route]
.where((String? value) => value != null && value.isNotEmpty)
.join(' ')
.trim();
return _PlaceAddress(
street: streetValue.isEmpty == true ? null : streetValue,
city: city,
state: state,
country: country,
zipCode: zipCode,
);
} catch (_) {
return null;
}
}
}
class _PlaceAddress {
const _PlaceAddress({
this.street,
this.city,
this.state,
this.country,
this.zipCode,
});
final String? street;
final String? city;
final String? state;
final String? country;
final String? zipCode;
}

View File

@@ -1,493 +1,89 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import '../../domain/entities/daily_ops_report.dart';
import '../../domain/entities/spend_report.dart';
import '../../domain/entities/coverage_report.dart';
import '../../domain/entities/forecast_report.dart';
import '../../domain/entities/performance_report.dart';
import '../../domain/entities/no_show_report.dart';
import '../../domain/entities/reports_summary.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/reports_repository.dart';
/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class ReportsRepositoryImpl implements ReportsRepository {
final DataConnectService _service;
final ReportsConnectorRepository _connectorRepository;
ReportsRepositoryImpl({DataConnectService? service})
: _service = service ?? DataConnectService.instance;
ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository})
: _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository();
@override
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final response = await _service.connector
.listShiftsForDailyOpsByBusiness(
businessId: id,
date: _service.toTimestamp(date),
)
.execute();
final shifts = response.data.shifts;
int scheduledShifts = shifts.length;
int workersConfirmed = 0;
int inProgressShifts = 0;
int completedShifts = 0;
final List<DailyOpsShift> dailyOpsShifts = [];
for (final shift in shifts) {
workersConfirmed += shift.filled ?? 0;
final statusStr = shift.status?.stringValue ?? '';
if (statusStr == 'IN_PROGRESS') inProgressShifts++;
if (statusStr == 'COMPLETED') completedShifts++;
dailyOpsShifts.add(DailyOpsShift(
id: shift.id,
title: shift.title ?? '',
location: shift.location ?? '',
startTime: shift.startTime?.toDateTime() ?? DateTime.now(),
endTime: shift.endTime?.toDateTime() ?? DateTime.now(),
workersNeeded: shift.workersNeeded ?? 0,
filled: shift.filled ?? 0,
status: statusStr,
));
}
return DailyOpsReport(
scheduledShifts: scheduledShifts,
workersConfirmed: workersConfirmed,
inProgressShifts: inProgressShifts,
completedShifts: completedShifts,
shifts: dailyOpsShifts,
}) => _connectorRepository.getDailyOpsReport(
businessId: businessId,
date: date,
);
});
}
@override
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final response = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final invoices = response.data.invoices;
double totalSpend = 0.0;
int paidInvoices = 0;
int pendingInvoices = 0;
int overdueInvoices = 0;
final List<SpendInvoice> spendInvoices = [];
final Map<DateTime, double> dailyAggregates = {};
final Map<String, double> industryAggregates = {};
for (final inv in invoices) {
final amount = (inv.amount ?? 0.0).toDouble();
totalSpend += amount;
final statusStr = inv.status.stringValue;
if (statusStr == 'PAID') {
paidInvoices++;
} else if (statusStr == 'PENDING') {
pendingInvoices++;
} else if (statusStr == 'OVERDUE') {
overdueInvoices++;
}
final industry = inv.vendor?.serviceSpecialty ?? 'Other';
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
final issueDateTime = inv.issueDate.toDateTime();
spendInvoices.add(SpendInvoice(
id: inv.id,
invoiceNumber: inv.invoiceNumber ?? '',
issueDate: issueDateTime,
amount: amount,
status: statusStr,
vendorName: inv.vendor?.companyName ?? 'Unknown',
industry: industry,
));
// Chart data aggregation
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
}
// Ensure chart data covers all days in range
final Map<DateTime, double> completeDailyAggregates = {};
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
final date = startDate.add(Duration(days: i));
final normalizedDate = DateTime(date.year, date.month, date.day);
completeDailyAggregates[normalizedDate] =
dailyAggregates[normalizedDate] ?? 0.0;
}
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
.toList()
..sort((a, b) => a.date.compareTo(b.date));
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
.map((e) => SpendIndustryCategory(
name: e.key,
amount: e.value,
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
))
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
final daysCount = endDate.difference(startDate).inDays + 1;
return SpendReport(
totalSpend: totalSpend,
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
paidInvoices: paidInvoices,
pendingInvoices: pendingInvoices,
overdueInvoices: overdueInvoices,
invoices: spendInvoices,
chartData: chartData,
industryBreakdown: industryBreakdown,
}) => _connectorRepository.getSpendReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
@override
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final response = await _service.connector
.listShiftsForCoverage(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
final Map<DateTime, (int, int)> dailyStats = {};
for (final shift in shifts) {
final shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final needed = shift.workersNeeded ?? 0;
final filled = shift.filled ?? 0;
totalNeeded += needed;
totalFilled += filled;
final current = dailyStats[date] ?? (0, 0);
dailyStats[date] = (current.$1 + needed, current.$2 + filled);
}
final List<CoverageDay> dailyCoverage = dailyStats.entries.map((e) {
final needed = e.value.$1;
final filled = e.value.$2;
return CoverageDay(
date: e.key,
needed: needed,
filled: filled,
percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0,
);
}).toList()..sort((a, b) => a.date.compareTo(b.date));
return CoverageReport(
overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
dailyCoverage: dailyCoverage,
}) => _connectorRepository.getCoverageReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
@override
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final response = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final shifts = response.data.shifts;
double projectedSpend = 0.0;
int projectedWorkers = 0;
final Map<DateTime, (double, int)> dailyStats = {};
for (final shift in shifts) {
final shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final cost = (shift.cost ?? 0.0).toDouble();
final workers = shift.workersNeeded ?? 0;
projectedSpend += cost;
projectedWorkers += workers;
final current = dailyStats[date] ?? (0.0, 0);
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
}
final List<ForecastPoint> chartData = dailyStats.entries.map((e) {
return ForecastPoint(
date: e.key,
projectedCost: e.value.$1,
workersNeeded: e.value.$2,
);
}).toList()..sort((a, b) => a.date.compareTo(b.date));
return ForecastReport(
projectedSpend: projectedSpend,
projectedWorkers: projectedWorkers,
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
chartData: chartData,
}) => _connectorRepository.getForecastReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
@override
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final response = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
int completedCount = 0;
double totalFillTimeSeconds = 0.0;
int filledShiftsWithTime = 0;
for (final shift in shifts) {
totalNeeded += shift.workersNeeded ?? 0;
totalFilled += shift.filled ?? 0;
if ((shift.status?.stringValue ?? '') == 'COMPLETED') {
completedCount++;
}
if (shift.filledAt != null && shift.createdAt != null) {
final createdAt = shift.createdAt!.toDateTime();
final filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0;
final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0;
final double avgFillTimeHours = filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600;
return PerformanceReport(
fillRate: fillRate,
completionRate: completionRate,
onTimeRate: 95.0,
avgFillTimeHours: avgFillTimeHours,
keyPerformanceIndicators: [
PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02),
PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05),
PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1),
],
}) => _connectorRepository.getPerformanceReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
@override
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final shiftsResponse = await _service.connector
.listShiftsForNoShowRangeByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final shiftIds = shiftsResponse.data.shifts.map((s) => s.id).toList();
if (shiftIds.isEmpty) {
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []);
}
final appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final apps = appsResponse.data.applications;
final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList();
final noShowStaffIds = noShowApps.map((a) => a.staffId).toSet().toList();
if (noShowStaffIds.isEmpty) {
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: [],
);
}
final staffResponse = await _service.connector
.listStaffForNoShowReport(staffIds: noShowStaffIds)
.execute();
final staffList = staffResponse.data.staffs;
final List<NoShowWorker> flaggedWorkers = staffList.map((s) => NoShowWorker(
id: s.id,
fullName: s.fullName ?? '',
noShowCount: s.noShowCount ?? 0,
reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(),
)).toList();
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: flaggedWorkers,
}) => _connectorRepository.getNoShowReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
@override
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return await _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
// Use forecast query for hours/cost data
final shiftsResponse = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
// Use performance query for avgFillTime (has filledAt + createdAt)
final perfResponse = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final invoicesResponse = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final forecastShifts = shiftsResponse.data.shifts;
final perfShifts = perfResponse.data.shifts;
final invoices = invoicesResponse.data.invoices;
// Aggregate hours and fill rate from forecast shifts
double totalHours = 0;
int totalNeeded = 0;
int totalFilled = 0;
for (final shift in forecastShifts) {
totalHours += (shift.hours ?? 0).toDouble();
totalNeeded += shift.workersNeeded ?? 0;
// Forecast query doesn't have 'filled' — use workersNeeded as proxy
// (fill rate will be computed from performance shifts below)
}
// Aggregate fill rate from performance shifts (has 'filled' field)
int perfNeeded = 0;
int perfFilled = 0;
double totalFillTimeSeconds = 0;
int filledShiftsWithTime = 0;
for (final shift in perfShifts) {
perfNeeded += shift.workersNeeded ?? 0;
perfFilled += shift.filled ?? 0;
if (shift.filledAt != null && shift.createdAt != null) {
final createdAt = shift.createdAt!.toDateTime();
final filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
// Aggregate total spend from invoices
double totalSpend = 0;
for (final inv in invoices) {
totalSpend += (inv.amount ?? 0).toDouble();
}
// Fetch no-show rate using forecast shift IDs
final shiftIds = forecastShifts.map((s) => s.id).toList();
double noShowRate = 0;
if (shiftIds.isNotEmpty) {
final appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final apps = appsResponse.data.applications;
final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList();
noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0;
}
final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0;
return ReportsSummary(
totalHours: totalHours,
otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it
totalSpend: totalSpend,
fillRate: fillRate,
avgFillTimeHours: filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600,
noShowRate: noShowRate,
}) => _connectorRepository.getReportsSummary(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
});
}
}

View File

@@ -1,35 +0,0 @@
import 'package:equatable/equatable.dart';
class CoverageReport extends Equatable {
final double overallCoverage;
final int totalNeeded;
final int totalFilled;
final List<CoverageDay> dailyCoverage;
const CoverageReport({
required this.overallCoverage,
required this.totalNeeded,
required this.totalFilled,
required this.dailyCoverage,
});
@override
List<Object?> get props => [overallCoverage, totalNeeded, totalFilled, dailyCoverage];
}
class CoverageDay extends Equatable {
final DateTime date;
final int needed;
final int filled;
final double percentage;
const CoverageDay({
required this.date,
required this.needed,
required this.filled,
required this.percentage,
});
@override
List<Object?> get props => [date, needed, filled, percentage];
}

View File

@@ -1,63 +0,0 @@
import 'package:equatable/equatable.dart';
class DailyOpsReport extends Equatable {
final int scheduledShifts;
final int workersConfirmed;
final int inProgressShifts;
final int completedShifts;
final List<DailyOpsShift> shifts;
const DailyOpsReport({
required this.scheduledShifts,
required this.workersConfirmed,
required this.inProgressShifts,
required this.completedShifts,
required this.shifts,
});
@override
List<Object?> get props => [
scheduledShifts,
workersConfirmed,
inProgressShifts,
completedShifts,
shifts,
];
}
class DailyOpsShift extends Equatable {
final String id;
final String title;
final String location;
final DateTime startTime;
final DateTime endTime;
final int workersNeeded;
final int filled;
final String status;
final double? hourlyRate;
const DailyOpsShift({
required this.id,
required this.title,
required this.location,
required this.startTime,
required this.endTime,
required this.workersNeeded,
required this.filled,
required this.status,
this.hourlyRate,
});
@override
List<Object?> get props => [
id,
title,
location,
startTime,
endTime,
workersNeeded,
filled,
status,
hourlyRate,
];
}

View File

@@ -1,33 +0,0 @@
import 'package:equatable/equatable.dart';
class ForecastReport extends Equatable {
final double projectedSpend;
final int projectedWorkers;
final double averageLaborCost;
final List<ForecastPoint> chartData;
const ForecastReport({
required this.projectedSpend,
required this.projectedWorkers,
required this.averageLaborCost,
required this.chartData,
});
@override
List<Object?> get props => [projectedSpend, projectedWorkers, averageLaborCost, chartData];
}
class ForecastPoint extends Equatable {
final DateTime date;
final double projectedCost;
final int workersNeeded;
const ForecastPoint({
required this.date,
required this.projectedCost,
required this.workersNeeded,
});
@override
List<Object?> get props => [date, projectedCost, workersNeeded];
}

View File

@@ -1,33 +0,0 @@
import 'package:equatable/equatable.dart';
class NoShowReport extends Equatable {
final int totalNoShows;
final double noShowRate;
final List<NoShowWorker> flaggedWorkers;
const NoShowReport({
required this.totalNoShows,
required this.noShowRate,
required this.flaggedWorkers,
});
@override
List<Object?> get props => [totalNoShows, noShowRate, flaggedWorkers];
}
class NoShowWorker extends Equatable {
final String id;
final String fullName;
final int noShowCount;
final double reliabilityScore;
const NoShowWorker({
required this.id,
required this.fullName,
required this.noShowCount,
required this.reliabilityScore,
});
@override
List<Object?> get props => [id, fullName, noShowCount, reliabilityScore];
}

View File

@@ -1,35 +0,0 @@
import 'package:equatable/equatable.dart';
class PerformanceReport extends Equatable {
final double fillRate;
final double completionRate;
final double onTimeRate;
final double avgFillTimeHours; // in hours
final List<PerformanceMetric> keyPerformanceIndicators;
const PerformanceReport({
required this.fillRate,
required this.completionRate,
required this.onTimeRate,
required this.avgFillTimeHours,
required this.keyPerformanceIndicators,
});
@override
List<Object?> get props => [fillRate, completionRate, onTimeRate, avgFillTimeHours, keyPerformanceIndicators];
}
class PerformanceMetric extends Equatable {
final String label;
final String value;
final double trend; // e.g. 0.05 for +5%
const PerformanceMetric({
required this.label,
required this.value,
required this.trend,
});
@override
List<Object?> get props => [label, value, trend];
}

View File

@@ -1,29 +0,0 @@
import 'package:equatable/equatable.dart';
class ReportsSummary extends Equatable {
final double totalHours;
final double otHours;
final double totalSpend;
final double fillRate;
final double avgFillTimeHours;
final double noShowRate;
const ReportsSummary({
required this.totalHours,
required this.otHours,
required this.totalSpend,
required this.fillRate,
required this.avgFillTimeHours,
required this.noShowRate,
});
@override
List<Object?> get props => [
totalHours,
otHours,
totalSpend,
fillRate,
avgFillTimeHours,
noShowRate,
];
}

View File

@@ -1,85 +0,0 @@
import 'package:equatable/equatable.dart';
class SpendReport extends Equatable {
final double totalSpend;
final double averageCost;
final int paidInvoices;
final int pendingInvoices;
final int overdueInvoices;
final List<SpendInvoice> invoices;
final List<SpendChartPoint> chartData;
const SpendReport({
required this.totalSpend,
required this.averageCost,
required this.paidInvoices,
required this.pendingInvoices,
required this.overdueInvoices,
required this.invoices,
required this.chartData,
required this.industryBreakdown,
});
final List<SpendIndustryCategory> industryBreakdown;
@override
List<Object?> get props => [
totalSpend,
averageCost,
paidInvoices,
pendingInvoices,
overdueInvoices,
invoices,
chartData,
industryBreakdown,
];
}
class SpendIndustryCategory extends Equatable {
final String name;
final double amount;
final double percentage;
const SpendIndustryCategory({
required this.name,
required this.amount,
required this.percentage,
});
@override
List<Object?> get props => [name, amount, percentage];
}
class SpendInvoice extends Equatable {
final String id;
final String invoiceNumber;
final DateTime issueDate;
final double amount;
final String status;
final String vendorName;
const SpendInvoice({
required this.id,
required this.invoiceNumber,
required this.issueDate,
required this.amount,
required this.status,
required this.vendorName,
this.industry,
});
final String? industry;
@override
List<Object?> get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry];
}
class SpendChartPoint extends Equatable {
final DateTime date;
final double amount;
const SpendChartPoint({required this.date, required this.amount});
@override
List<Object?> get props => [date, amount];
}

View File

@@ -1,10 +1,4 @@
import '../entities/daily_ops_report.dart';
import '../entities/spend_report.dart';
import '../entities/coverage_report.dart';
import '../entities/forecast_report.dart';
import '../entities/performance_report.dart';
import '../entities/no_show_report.dart';
import '../entities/reports_summary.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class ReportsRepository {
Future<DailyOpsReport> getDailyOpsReport({

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/daily_ops_report.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class DailyOpsState extends Equatable {
const DailyOpsState();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/forecast_report.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class ForecastState extends Equatable {
const ForecastState();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/no_show_report.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class NoShowState extends Equatable {
const NoShowState();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/performance_report.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class PerformanceState extends Equatable {
const PerformanceState();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/spend_report.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class SpendState extends Equatable {
const SpendState();

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/reports_summary.dart';
import 'package:krow_domain/krow_domain.dart';
abstract class ReportsSummaryState extends Equatable {
const ReportsSummaryState();

View File

@@ -0,0 +1,300 @@
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
class CoverageReportPage extends StatefulWidget {
const CoverageReportPage({super.key});
@override
State<CoverageReportPage> createState() => _CoverageReportPageState();
}
class _CoverageReportPageState extends State<CoverageReportPage> {
final DateTime _startDate = DateTime.now();
final DateTime _endDate = DateTime.now().add(const Duration(days: 14));
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<CoverageBloc>()
..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<CoverageBloc, CoverageState>(
builder: (context, state) {
if (state is CoverageLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is CoverageError) {
return Center(child: Text(state.message));
}
if (state is CoverageLoaded) {
final report = state.report;
return SingleChildScrollView(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.only(
top: 60,
left: 20,
right: 20,
bottom: 32,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [UiColors.primary, UiColors.tagInProgress],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.coverage_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.coverage_report
.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
],
),
),
// Content
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Summary Cards
Row(
children: [
Expanded(
child: _CoverageSummaryCard(
label: context.t.client_reports.coverage_report.metrics.avg_coverage,
value: '${report.overallCoverage.toStringAsFixed(1)}%',
icon: UiIcons.chart,
color: UiColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: _CoverageSummaryCard(
label: context.t.client_reports.coverage_report.metrics.full,
value: '${report.totalFilled}/${report.totalNeeded}',
icon: UiIcons.users,
color: UiColors.success,
),
),
],
),
const SizedBox(height: 24),
// Daily List
Text(
context.t.client_reports.coverage_report.next_7_days,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
if (report.dailyCoverage.isEmpty)
Center(child: Text(context.t.client_reports.coverage_report.empty_state))
else
...report.dailyCoverage.map((day) => _CoverageListItem(
date: DateFormat('EEE, MMM dd').format(day.date),
needed: day.needed,
filled: day.filled,
percentage: day.percentage,
)),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _CoverageSummaryCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _CoverageSummaryCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: color),
),
const SizedBox(height: 12),
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
);
}
}
class _CoverageListItem extends StatelessWidget {
final String date;
final int needed;
final int filled;
final double percentage;
const _CoverageListItem({
required this.date,
required this.needed,
required this.filled,
required this.percentage,
});
@override
Widget build(BuildContext context) {
Color statusColor;
if (percentage >= 100) {
statusColor = UiColors.success;
} else if (percentage >= 80) {
statusColor = UiColors.textWarning;
} else {
statusColor = UiColors.destructive;
}
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
// Progress Bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage / 100,
backgroundColor: UiColors.bgMenu,
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
minHeight: 6,
),
),
],
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$filled/$needed',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
],
),
],
),
);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart';
import 'package:client_reports/src/domain/entities/forecast_report.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:fl_chart/fl_chart.dart';
@@ -18,8 +18,8 @@ class ForecastReportPage extends StatefulWidget {
}
class _ForecastReportPageState extends State<ForecastReportPage> {
DateTime _startDate = DateTime.now();
DateTime _endDate = DateTime.now().add(const Duration(days: 14));
final DateTime _startDate = DateTime.now();
final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks
@override
Widget build(BuildContext context) {
@@ -44,159 +44,48 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.only(
top: 60,
left: 20,
right: 20,
bottom: 32,
),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [UiColors.primary, UiColors.tagInProgress],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.forecast_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.forecast_report
.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
],
),
),
_buildHeader(context),
// Content
Transform.translate(
offset: const Offset(0, -16),
offset: const Offset(0, -20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Summary Cards
Row(
children: [
Expanded(
child: _ForecastSummaryCard(
label: context.t.client_reports.forecast_report.metrics.projected_spend,
value: NumberFormat.currency(symbol: r'$')
.format(report.projectedSpend),
icon: UiIcons.dollar,
color: UiColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: _ForecastSummaryCard(
label: context.t.client_reports.forecast_report.metrics.workers_needed,
value: report.projectedWorkers.toString(),
icon: UiIcons.users,
color: UiColors.primary,
),
),
],
),
const SizedBox(height: 24),
// Chart
Container(
height: 300,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.forecast_report.chart_title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 24),
Expanded(
child: _ForecastChart(
points: report.chartData,
),
),
],
),
),
const SizedBox(height: 24),
// Daily List
Text(
context.t.client_reports.forecast_report.daily_projections,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
// Metrics Grid
_buildMetricsGrid(context, report),
const SizedBox(height: 16),
if (report.chartData.isEmpty)
Center(child: Text(context.t.client_reports.forecast_report.empty_state))
// Chart Section
_buildChartSection(context, report),
const SizedBox(height: 24),
// Weekly Breakdown Title
Text(
context.t.client_reports.forecast_report.weekly_breakdown.title,
style: UiTypography.titleUppercase2m.textSecondary,
),
const SizedBox(height: 12),
// Weekly Breakdown List
if (report.weeklyBreakdown.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Text(
context.t.client_reports.forecast_report.empty_state,
style: UiTypography.body2r.textSecondary,
),
),
)
else
...report.chartData.map((point) => _ForecastListItem(
date: DateFormat('EEE, MMM dd').format(point.date),
cost: NumberFormat.currency(symbol: r'$')
.format(point.projectedCost),
workers: point.workersNeeded.toString(),
)),
const SizedBox(height: 100),
...report.weeklyBreakdown.map(
(week) => _WeeklyBreakdownItem(week: week),
),
const SizedBox(height: 40),
],
),
),
@@ -211,25 +100,135 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
),
);
}
}
class _ForecastSummaryCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _ForecastSummaryCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(
top: 60,
left: 20,
right: 20,
bottom: 40,
),
decoration: const BoxDecoration(
color: UiColors.primary,
gradient: LinearGradient(
colors: [UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.t.client_reports.forecast_report.title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
context.t.client_reports.forecast_report.subtitle,
style: UiTypography.body2m.copyWith(
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
/*
UiButton.secondary(
text: context.t.client_reports.forecast_report.buttons.export,
leadingIcon: UiIcons.download,
onPressed: () {
// Placeholder export action
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.t.client_reports.forecast_report.placeholders.export_message),
),
);
},
// If button variants are limited, we might need a custom button or adjust design system usage
// Since I can't easily see UiButton implementation details beyond exports, I'll stick to a standard usage.
// If UiButton doesn't look right on blue bg, I count rely on it being white/transparent based on tokens.
),
*/
],
),
);
}
Widget _buildMetricsGrid(BuildContext context, ForecastReport report) {
final t = context.t.client_reports.forecast_report;
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.3,
children: [
_MetricCard(
icon: UiIcons.dollar,
label: t.metrics.four_week_forecast,
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend),
badgeText: t.badges.total_projected,
iconColor: UiColors.textWarning,
badgeColor: UiColors.tagPending, // Yellow-ish
),
_MetricCard(
icon: UiIcons.trendingUp,
label: t.metrics.avg_weekly,
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend),
badgeText: t.badges.per_week,
iconColor: UiColors.primary,
badgeColor: UiColors.tagInProgress, // Blue-ish
),
_MetricCard(
icon: UiIcons.calendar,
label: t.metrics.total_shifts,
value: report.totalShifts.toString(),
badgeText: t.badges.scheduled,
iconColor: const Color(0xFF9333EA), // Purple
badgeColor: const Color(0xFFF3E8FF), // Purple light
),
_MetricCard(
icon: UiIcons.users,
label: t.metrics.total_hours,
value: report.totalHours.toStringAsFixed(0),
badgeText: t.badges.worker_hours,
iconColor: UiColors.success,
badgeColor: UiColors.tagSuccess,
),
],
);
}
Widget _buildChartSection(BuildContext context, ForecastReport report) {
return Container(
height: 320,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
@@ -243,24 +242,178 @@ class _ForecastSummaryCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: color),
Text(
context.t.client_reports.forecast_report.chart_title,
style: UiTypography.headline4m,
),
const SizedBox(height: 8),
Text(
r'$15k', // Example Y-axis label placeholder or dynamic max
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(height: 24),
Expanded(
child: _ForecastChart(points: report.chartData),
),
const SizedBox(height: 8),
// X Axis labels manually if chart doesn't handle them perfectly or for custom look
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text('W1', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W1', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W2', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W2', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W3', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W3', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W4', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
],
),
const SizedBox(height: 12),
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
);
}
}
class _MetricCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final String badgeText;
final Color iconColor;
final Color badgeColor;
const _MetricCard({
required this.icon,
required this.label,
required this.value,
required this.badgeText,
required this.iconColor,
required this.badgeColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
),
],
),
Text(
value,
style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(6),
),
child: Text(
badgeText,
style: UiTypography.footnote1r.copyWith(
color: UiColors.textPrimary, // Or specific text color
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _WeeklyBreakdownItem extends StatelessWidget {
final ForecastWeek week;
const _WeeklyBreakdownItem({required this.week});
@override
Widget build(BuildContext context) {
final t = context.t.client_reports.forecast_report.weekly_breakdown;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
t.week(index: week.weekNumber),
style: UiTypography.headline4m,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: UiColors.tagPending,
borderRadius: BorderRadius.circular(8),
),
child: Text(
NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.totalCost),
style: UiTypography.body2b.copyWith(
color: UiColors.textWarning,
),
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildStat(t.shifts, week.shiftsCount.toString()),
_buildStat(t.hours, week.hoursCount.toStringAsFixed(0)),
_buildStat(t.avg_shift, NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.avgCostPerShift)),
],
),
],
),
);
}
Widget _buildStat(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: UiTypography.footnote1r.textSecondary),
const SizedBox(height: 4),
Text(value, style: UiTypography.body1m),
],
);
}
}
class _ForecastChart extends StatelessWidget {
final List<ForecastPoint> points;
@@ -268,51 +421,51 @@ class _ForecastChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
// If no data, show empty or default line?
if (points.isEmpty) return const SizedBox();
return LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: FlTitlesData(
gridData: FlGridData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() < 0 || value.toInt() >= points.length) {
return const SizedBox();
}
if (value.toInt() % 3 != 0) return const SizedBox();
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
DateFormat('dd').format(points[value.toInt()].date),
style: const TextStyle(fontSize: 10, color: UiColors.textSecondary),
),
);
},
),
),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
drawVerticalLine: false,
horizontalInterval: 5000, // Dynamic?
getDrawingHorizontalLine: (value) {
return FlLine(
color: UiColors.borderInactive,
strokeWidth: 1,
dashArray: [5, 5],
);
},
),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
minX: 0,
maxX: points.length.toDouble() - 1,
// minY: 0, // Let it scale automatically
lineBarsData: [
LineChartBarData(
spots: points
.asMap()
.entries
.map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost))
.toList(),
spots: points.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.projectedCost);
}).toList(),
isCurved: true,
color: UiColors.primary,
color: UiColors.textWarning, // Orange-ish
barWidth: 4,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: UiColors.textWarning,
strokeWidth: 2,
strokeColor: UiColors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
color: UiColors.primary.withOpacity(0.1),
color: UiColors.tagPending.withOpacity(0.5), // Light orange fill
),
),
],
@@ -320,40 +473,3 @@ class _ForecastChart extends StatelessWidget {
);
}
}
class _ForecastListItem extends StatelessWidget {
final String date;
final String cost;
final String workers;
const _ForecastListItem({
required this.date,
required this.cost,
required this.workers,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
],
),
Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)),
],
),
);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:client_reports/src/domain/entities/no_show_report.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';

View File

@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:client_reports/src/domain/entities/spend_report.dart';
import 'package:krow_domain/krow_domain.dart';
class SpendReportPage extends StatefulWidget {
const SpendReportPage({super.key});

View File

@@ -50,6 +50,14 @@ class QuickReportsSection extends StatelessWidget {
iconColor: UiColors.success,
route: './spend',
),
// Coverage Report
ReportCard(
icon: UiIcons.users,
name: context.t.client_reports.quick_reports.cards.coverage,
iconBgColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
route: './coverage',
),
// No-Show Rates
ReportCard(
icon: UiIcons.warning,
@@ -58,6 +66,14 @@ class QuickReportsSection extends StatelessWidget {
iconColor: UiColors.destructive,
route: './no-show',
),
// Forecast Report
ReportCard(
icon: UiIcons.trendingUp,
name: context.t.client_reports.quick_reports.cards.forecast,
iconBgColor: UiColors.tagPending,
iconColor: UiColors.textWarning,
route: './forecast',
),
// Performance Reports
ReportCard(
icon: UiIcons.chart,

View File

@@ -12,6 +12,8 @@ import 'package:client_reports/src/presentation/pages/no_show_report_page.dart';
import 'package:client_reports/src/presentation/pages/performance_report_page.dart';
import 'package:client_reports/src/presentation/pages/reports_page.dart';
import 'package:client_reports/src/presentation/pages/spend_report_page.dart';
import 'package:client_reports/src/presentation/pages/coverage_report_page.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
@@ -24,6 +26,7 @@ class ReportsModule extends Module {
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
i.add<DailyOpsBloc>(DailyOpsBloc.new);
i.add<SpendBloc>(SpendBloc.new);
i.add<CoverageBloc>(CoverageBloc.new);
i.add<ForecastBloc>(ForecastBloc.new);
i.add<PerformanceBloc>(PerformanceBloc.new);
i.add<NoShowBloc>(NoShowBloc.new);
@@ -35,6 +38,7 @@ class ReportsModule extends Module {
r.child('/', child: (_) => const ReportsPage());
r.child('/daily-ops', child: (_) => const DailyOpsReportPage());
r.child('/spend', child: (_) => const SpendReportPage());
r.child('/coverage', child: (_) => const CoverageReportPage());
r.child('/forecast', child: (_) => const ForecastReportPage());
r.child('/performance', child: (_) => const PerformanceReportPage());
r.child('/no-show', child: (_) => const NoShowReportPage());

View File

@@ -29,6 +29,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
on<PersonalInfoFieldChanged>(_onFieldChanged);
on<PersonalInfoAddressSelected>(_onAddressSelected);
on<PersonalInfoFormSubmitted>(_onSubmitted);
on<PersonalInfoLocationAdded>(_onLocationAdded);
on<PersonalInfoLocationRemoved>(_onLocationRemoved);
add(const PersonalInfoLoadRequested());
}
@@ -133,11 +135,48 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
PersonalInfoAddressSelected event,
Emitter<PersonalInfoState> emit,
) {
// TODO: Implement Google Places logic if needed
// Legacy address selected no-op; use PersonalInfoLocationAdded instead.
}
/// With _onPhotoUploadRequested and _onSaveRequested removed or renamed,
/// there are no errors pointing to them here.
/// Adds a location to the preferredLocations list (max 5, no duplicates).
void _onLocationAdded(
PersonalInfoLocationAdded event,
Emitter<PersonalInfoState> emit,
) {
final dynamic raw = state.formValues['preferredLocations'];
final List<String> current = _toStringList(raw);
if (current.length >= 5) return; // max guard
if (current.contains(event.location)) return; // no duplicates
final List<String> updated = List<String>.from(current)..add(event.location);
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
emit(state.copyWith(formValues: updatedValues));
}
/// Removes a location from the preferredLocations list.
void _onLocationRemoved(
PersonalInfoLocationRemoved event,
Emitter<PersonalInfoState> emit,
) {
final dynamic raw = state.formValues['preferredLocations'];
final List<String> current = _toStringList(raw);
final List<String> updated = List<String>.from(current)
..remove(event.location);
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
emit(state.copyWith(formValues: updatedValues));
}
List<String> _toStringList(dynamic raw) {
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
return <String>[];
}
@override
void dispose() {

View File

@@ -40,3 +40,21 @@ class PersonalInfoAddressSelected extends PersonalInfoEvent {
@override
List<Object?> get props => <Object?>[address];
}
/// Event to add a preferred location.
class PersonalInfoLocationAdded extends PersonalInfoEvent {
const PersonalInfoLocationAdded({required this.location});
final String location;
@override
List<Object?> get props => <Object?>[location];
}
/// Event to remove a preferred location.
class PersonalInfoLocationRemoved extends PersonalInfoEvent {
const PersonalInfoLocationRemoved({required this.location});
final String location;
@override
List<Object?> get props => <Object?>[location];
}

View File

@@ -0,0 +1,513 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:google_places_flutter/google_places_flutter.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_core/core.dart';
import '../blocs/personal_info_bloc.dart';
import '../blocs/personal_info_event.dart';
import '../blocs/personal_info_state.dart';
/// The maximum number of preferred locations a staff member can add.
const int _kMaxLocations = 5;
/// Uber-style Preferred Locations editing page.
///
/// Allows staff to search for US locations using the Google Places API,
/// add them as chips (max 5), and save back to their profile.
class PreferredLocationsPage extends StatefulWidget {
/// Creates a [PreferredLocationsPage].
const PreferredLocationsPage({super.key});
@override
State<PreferredLocationsPage> createState() => _PreferredLocationsPageState();
}
class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
late final TextEditingController _searchController;
late final FocusNode _searchFocusNode;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
}
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _onLocationSelected(Prediction prediction, PersonalInfoBloc bloc) {
final String description = prediction.description ?? '';
if (description.isEmpty) return;
bloc.add(PersonalInfoLocationAdded(location: description));
// Clear search field after selection
_searchController.clear();
_searchFocusNode.unfocus();
}
void _removeLocation(String location, PersonalInfoBloc bloc) {
bloc.add(PersonalInfoLocationRemoved(location: location));
}
void _save(BuildContext context, PersonalInfoBloc bloc, PersonalInfoState state) {
bloc.add(const PersonalInfoFormSubmitted());
}
@override
Widget build(BuildContext context) {
final i18n = t.staff.onboarding.personal_info;
// Access the same PersonalInfoBloc singleton managed by the module.
final PersonalInfoBloc bloc = Modular.get<PersonalInfoBloc>();
return BlocProvider<PersonalInfoBloc>.value(
value: bloc,
child: BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
listener: (BuildContext context, PersonalInfoState state) {
if (state.status == PersonalInfoStatus.saved) {
UiSnackbar.show(
context,
message: i18n.preferred_locations.save_success,
type: UiSnackbarType.success,
);
Navigator.of(context).pop();
} else if (state.status == PersonalInfoStatus.error) {
UiSnackbar.show(
context,
message: state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, PersonalInfoState state) {
final List<String> locations = _currentLocations(state);
final bool atMax = locations.length >= _kMaxLocations;
final bool isSaving = state.status == PersonalInfoStatus.saving;
return Scaffold(
backgroundColor: UiColors.background,
appBar: AppBar(
backgroundColor: UiColors.bgPopup,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Navigator.of(context).pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
),
title: Text(
i18n.preferred_locations.title,
style: UiTypography.title1m.textPrimary,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// ── Description
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Text(
i18n.preferred_locations.description,
style: UiTypography.body2r.textSecondary,
),
),
// ── Search autocomplete field
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: _PlacesSearchField(
controller: _searchController,
focusNode: _searchFocusNode,
hint: i18n.preferred_locations.search_hint,
enabled: !atMax && !isSaving,
onSelected: (Prediction p) => _onLocationSelected(p, bloc),
),
),
// ── "Max reached" banner
if (atMax)
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space2,
UiConstants.space5,
0,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.info,
size: 14,
color: UiColors.textWarning,
),
const SizedBox(width: UiConstants.space1),
Text(
i18n.preferred_locations.max_reached,
style: UiTypography.footnote1r.textWarning,
),
],
),
),
const SizedBox(height: UiConstants.space5),
// ── Section label
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Text(
i18n.preferred_locations.added_label,
style: UiTypography.titleUppercase3m.textSecondary,
),
),
const SizedBox(height: UiConstants.space3),
// ── Locations list / empty state
Expanded(
child: locations.isEmpty
? _EmptyLocationsState(message: i18n.preferred_locations.empty_state)
: _LocationsList(
locations: locations,
isSaving: isSaving,
removeTooltip: i18n.preferred_locations.remove_tooltip,
onRemove: (String loc) => _removeLocation(loc, bloc),
),
),
// ── Save button
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: UiButton.primary(
text: i18n.preferred_locations.save_button,
fullWidth: true,
onPressed: isSaving ? null : () => _save(context, bloc, state),
),
),
],
),
),
);
},
),
);
}
List<String> _currentLocations(PersonalInfoState state) {
final dynamic raw = state.formValues['preferredLocations'];
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
return <String>[];
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Subwidgets
// ─────────────────────────────────────────────────────────────────────────────
/// Google Places autocomplete search field, locked to US results.
class _PlacesSearchField extends StatelessWidget {
const _PlacesSearchField({
required this.controller,
required this.focusNode,
required this.hint,
required this.onSelected,
this.enabled = true,
});
final TextEditingController controller;
final FocusNode focusNode;
final String hint;
final bool enabled;
final void Function(Prediction) onSelected;
@override
Widget build(BuildContext context) {
return GooglePlaceAutoCompleteTextField(
textEditingController: controller,
focusNode: focusNode,
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 400,
countries: const <String>['us'],
isLatLngRequired: false,
getPlaceDetailWithLatLng: onSelected,
itemClick: (Prediction prediction) {
controller.text = prediction.description ?? '';
controller.selection = TextSelection.fromPosition(
TextPosition(offset: controller.text.length),
);
onSelected(prediction);
},
inputDecoration: InputDecoration(
hintText: hint,
hintStyle: UiTypography.body2r.textSecondary,
prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary),
onPressed: controller.clear,
)
: null,
contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.primary, width: 1.5),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
filled: true,
),
textStyle: UiTypography.body2r.textPrimary,
itemBuilder: (BuildContext context, int index, Prediction prediction) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space2,
),
child: Row(
children: <Widget>[
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4.0),
),
child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
_mainText(prediction.description ?? ''),
style: UiTypography.body2m.textPrimary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_subText(prediction.description ?? '').isNotEmpty)
Text(
_subText(prediction.description ?? ''),
style: UiTypography.footnote1r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
},
);
}
/// Extracts text before first comma as the primary line.
String _mainText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(0, commaIndex) : description;
}
/// Extracts text after first comma as the secondary line.
String _subText(String description) {
final int commaIndex = description.indexOf(',');
return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : '';
}
}
/// The scrollable list of location chips.
class _LocationsList extends StatelessWidget {
const _LocationsList({
required this.locations,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final List<String> locations;
final bool isSaving;
final String removeTooltip;
final void Function(String) onRemove;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
itemCount: locations.length,
separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final String location = locations[index];
return _LocationChip(
label: location,
index: index + 1,
total: locations.length,
isSaving: isSaving,
removeTooltip: removeTooltip,
onRemove: () => onRemove(location),
);
},
);
}
}
/// A single location row with pin icon, label, and remove button.
class _LocationChip extends StatelessWidget {
const _LocationChip({
required this.label,
required this.index,
required this.total,
required this.isSaving,
required this.removeTooltip,
required this.onRemove,
});
final String label;
final int index;
final int total;
final bool isSaving;
final String removeTooltip;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
// Index badge
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Text(
'$index',
style: UiTypography.footnote1m.copyWith(color: UiColors.primary),
),
),
const SizedBox(width: UiConstants.space3),
// Pin icon
const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
// Location text
Expanded(
child: Text(
label,
style: UiTypography.body2m.textPrimary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Remove button
if (!isSaving)
Tooltip(
message: removeTooltip,
child: GestureDetector(
onTap: onRemove,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(UiConstants.space1),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
shape: BoxShape.circle,
),
child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary),
),
),
),
),
],
),
);
}
}
/// Shows when no locations have been added yet.
class _EmptyLocationsState extends StatelessWidget {
const _EmptyLocationsState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary),
),
const SizedBox(height: UiConstants.space4),
Text(
message,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -34,26 +34,22 @@ class PersonalInfoContent extends StatefulWidget {
class _PersonalInfoContentState extends State<PersonalInfoContent> {
late final TextEditingController _emailController;
late final TextEditingController _phoneController;
late final TextEditingController _locationsController;
@override
void initState() {
super.initState();
_emailController = TextEditingController(text: widget.staff.email);
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
// Listen to changes and update BLoC
_emailController.addListener(_onEmailChanged);
_phoneController.addListener(_onPhoneChanged);
_locationsController.addListener(_onAddressChanged);
}
@override
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_locationsController.dispose();
super.dispose();
}
@@ -76,23 +72,6 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
);
}
void _onAddressChanged() {
// Split the comma-separated string into a list for storage
// The backend expects List<AnyValue> (JSON/List) for preferredLocations
final List<String> locations = _locationsController.text
.split(',')
.map((String e) => e.trim())
.where((String e) => e.isNotEmpty)
.toList();
context.read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'preferredLocations',
value: locations,
),
);
}
void _handleSave() {
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
}
@@ -129,7 +108,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
email: widget.staff.email,
emailController: _emailController,
phoneController: _phoneController,
locationsController: _locationsController,
currentLocations: _toStringList(state.formValues['preferredLocations']),
enabled: !isSaving,
),
const SizedBox(height: UiConstants.space16), // Space for bottom button
@@ -147,4 +126,10 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
},
);
}
}
List<String> _toStringList(dynamic raw) {
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
return <String>[];
}
}

View File

@@ -4,11 +4,11 @@ import 'package:design_system/design_system.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// A form widget containing all personal information fields.
///
/// Includes read-only fields for full name and email,
/// and editable fields for phone and address.
/// Includes read-only fields for full name,
/// and editable fields for email and phone.
/// The Preferred Locations row navigates to a dedicated Uber-style page.
/// Uses only design system tokens for colors, typography, and spacing.
class PersonalInfoForm extends StatelessWidget {
@@ -19,7 +19,7 @@ class PersonalInfoForm extends StatelessWidget {
required this.email,
required this.emailController,
required this.phoneController,
required this.locationsController,
required this.currentLocations,
this.enabled = true,
});
/// The staff member's full name (read-only).
@@ -34,8 +34,8 @@ class PersonalInfoForm extends StatelessWidget {
/// Controller for the phone number field.
final TextEditingController phoneController;
/// Controller for the address field.
final TextEditingController locationsController;
/// Current preferred locations list to show in the summary row.
final List<String> currentLocations;
/// Whether the form fields are enabled for editing.
final bool enabled;
@@ -43,6 +43,9 @@ class PersonalInfoForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
final String locationSummary = currentLocations.isEmpty
? i18n.locations_summary_none
: currentLocations.join(', ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -69,15 +72,21 @@ class PersonalInfoForm extends StatelessWidget {
controller: phoneController,
hint: i18n.phone_hint,
enabled: enabled,
keyboardType: TextInputType.phone,
),
const SizedBox(height: UiConstants.space4),
_FieldLabel(text: i18n.locations_label),
const SizedBox(height: UiConstants.space2),
_EditableField(
controller: locationsController,
// Uber-style tappable row → navigates to PreferredLocationsPage
_TappableRow(
value: locationSummary,
hint: i18n.locations_hint,
icon: UiIcons.mapPin,
enabled: enabled,
onTap: enabled
? () => Modular.to.pushNamed(StaffPaths.preferredLocations)
: null,
),
const SizedBox(height: UiConstants.space4),
@@ -91,6 +100,68 @@ class PersonalInfoForm extends StatelessWidget {
}
}
/// An Uber-style tappable row for navigating to a sub-page editor.
/// Displays the current value (or hint if empty) and a chevron arrow.
class _TappableRow extends StatelessWidget {
const _TappableRow({
required this.value,
required this.hint,
required this.icon,
this.onTap,
this.enabled = true,
});
final String value;
final String hint;
final IconData icon;
final VoidCallback? onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
final bool hasValue = value.isNotEmpty;
return GestureDetector(
onTap: enabled ? onTap : null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
),
),
child: Row(
children: <Widget>[
Icon(icon, size: 18, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
hasValue ? value : hint,
style: hasValue
? UiTypography.body2r.textPrimary
: UiTypography.body2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (enabled)
Icon(
UiIcons.chevronRight,
size: 18,
color: UiColors.iconSecondary,
),
],
),
),
);
}
}
/// A language selector widget that displays the current language and navigates to language selection page.
class _LanguageSelector extends StatelessWidget {
const _LanguageSelector({
@@ -99,46 +170,43 @@ class _LanguageSelector extends StatelessWidget {
final bool enabled;
String _getLanguageLabel(AppLocale locale) {
switch (locale) {
case AppLocale.en:
return 'English';
case AppLocale.es:
return 'Español';
}
}
@override
Widget build(BuildContext context) {
final AppLocale currentLocale = LocaleSettings.currentLocale;
final String currentLanguage = _getLanguageLabel(currentLocale);
final String currentLocale = Localizations.localeOf(context).languageCode;
final String languageName = currentLocale == 'es' ? 'Español' : 'English';
return GestureDetector(
onTap: enabled
? () => Modular.to.pushNamed(StaffPaths.languageSelection)
: null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.bgPopup,
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(color: UiColors.border),
border: Border.all(
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
currentLanguage,
style: UiTypography.body2r.textPrimary,
),
Icon(
UiIcons.chevronRight,
color: UiColors.textSecondary,
const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
languageName,
style: UiTypography.body2r.textPrimary,
),
),
if (enabled)
const Icon(
UiIcons.chevronRight,
size: 18,
color: UiColors.iconSecondary,
),
],
),
),
@@ -146,10 +214,7 @@ class _LanguageSelector extends StatelessWidget {
}
}
/// A label widget for form fields.
/// A label widget for form fields.
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.text});
final String text;
@@ -157,13 +222,11 @@ class _FieldLabel extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
text,
style: UiTypography.body2m.textPrimary,
style: UiTypography.titleUppercase3m.textSecondary,
);
}
}
/// A read-only field widget for displaying non-editable information.
/// A read-only field widget for displaying non-editable information.
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({required this.value});
final String value;
@@ -183,14 +246,12 @@ class _ReadOnlyField extends StatelessWidget {
),
child: Text(
value,
style: UiTypography.body2r.textPrimary,
style: UiTypography.body2r.textInactive,
),
);
}
}
/// An editable text field widget.
/// An editable text field widget.
class _EditableField extends StatelessWidget {
const _EditableField({
required this.controller,
@@ -232,7 +293,7 @@ class _EditableField extends StatelessWidget {
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: const BorderSide(color: UiColors.primary),
),
fillColor: UiColors.bgPopup,
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
filled: true,
),
);

View File

@@ -9,6 +9,7 @@ import 'domain/usecases/update_personal_info_usecase.dart';
import 'presentation/blocs/personal_info_bloc.dart';
import 'presentation/pages/personal_info_page.dart';
import 'presentation/pages/language_selection_page.dart';
import 'presentation/pages/preferred_locations_page.dart';
/// The entry module for the Staff Profile Info feature.
///
@@ -61,5 +62,12 @@ class StaffProfileInfoModule extends Module {
),
child: (BuildContext context) => const LanguageSelectionPage(),
);
r.child(
StaffPaths.childRoute(
StaffPaths.onboardingPersonalInfo,
StaffPaths.preferredLocations,
),
child: (BuildContext context) => const PreferredLocationsPage(),
);
}
}

View File

@@ -30,6 +30,8 @@ dependencies:
firebase_auth: any
firebase_data_connect: any
google_places_flutter: ^2.1.1
http: ^1.2.2
dev_dependencies:
flutter_test:
sdk: flutter

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,
);
}
}