Merge branch '216-p0-staff-01-profile-setup-wizard' of https://github.com/Oloodi/krow-workforce into 216-p0-staff-01-profile-setup-wizard

This commit is contained in:
Achintha Isuru
2026-01-27 16:25:54 -05:00
20 changed files with 18947 additions and 17667 deletions

View File

@@ -22,22 +22,7 @@ class InvoiceHistorySection extends StatelessWidget {
t.client_billing.invoice_history,
style: UiTypography.title2b.textPrimary,
),
GestureDetector(
onTap: () {},
child: Row(
children: <Widget>[
Text(
t.client_billing.view_all,
style: UiTypography.footnote2b.textPrimary,
),
const Icon(
UiIcons.chevronRight,
size: 16,
color: UiColors.primary,
),
],
),
),
const SizedBox.shrink(),
],
),
const SizedBox(height: UiConstants.space2),
@@ -117,8 +102,7 @@ class _InvoiceItem extends StatelessWidget {
_StatusBadge(status: invoice.status),
],
),
const SizedBox(width: UiConstants.space2),
const Icon(UiIcons.download, size: 16, color: UiColors.iconSecondary),
const SizedBox.shrink(),
],
),
);

View File

@@ -69,29 +69,13 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
GestureDetector(
onTap: () {},
child: Row(
children: <Widget>[
const Icon(
UiIcons.add,
size: 14,
color: UiColors.primary,
),
const SizedBox(width: 4),
Text(
t.client_billing.add_payment,
style: UiTypography.footnote2b.textPrimary,
),
],
),
),
],
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
const SizedBox.shrink(),
],
),
if (account != null) ...<Widget>[
const SizedBox(height: UiConstants.space3),
Container(

View File

@@ -50,25 +50,7 @@ class SavingsCard extends StatelessWidget {
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
textStyle: UiTypography.footnote2b,
),
child: Text(t.client_billing.view_details),
),
),
const SizedBox.shrink(),
],
),
),

View File

@@ -1,4 +1,5 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/coverage_repository_impl.dart';
import 'domain/repositories/coverage_repository.dart';
import 'domain/usecases/get_coverage_stats_usecase.dart';
@@ -11,7 +12,9 @@ class CoverageModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addSingleton<CoverageRepository>(CoverageRepositoryImpl.new);
i.addSingleton<CoverageRepository>(
() => CoverageRepositoryImpl(dataConnect: ExampleConnector.instance),
);
// Use Cases
i.addSingleton(GetShiftsForDateUseCase.new);

View File

@@ -1,3 +1,5 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import '../../domain/repositories/coverage_repository.dart';
import '../../domain/ui_entities/coverage_entities.dart';
@@ -13,82 +15,58 @@ import '../../domain/ui_entities/coverage_entities.dart';
/// - Returns domain entities from `domain/ui_entities`.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl();
CoverageRepositoryImpl({required dc.ExampleConnector dataConnect})
: _dataConnect = dataConnect;
final dc.ExampleConnector _dataConnect;
/// Fetches shifts for a specific date.
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
// Simulate network delay
await Future<void>.delayed(const Duration(milliseconds: 500));
// Mock data - in production, this would come from data_connect
final DateTime today = DateTime.now();
final bool isToday = date.year == today.year &&
date.month == today.month &&
date.day == today.day;
if (!isToday) {
// Return empty list for non-today dates
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
print('Coverage: now=${DateTime.now().toIso8601String()}');
if (businessId == null || businessId.isEmpty) {
print('Coverage: missing businessId for date=${date.toIso8601String()}');
return <CoverageShift>[];
}
return <CoverageShift>[
CoverageShift(
id: '1',
title: 'Banquet Server',
location: 'Grand Ballroom',
startTime: '16:00',
workersNeeded: 10,
date: date,
workers: const <CoverageWorker>[
CoverageWorker(
name: 'Sarah Wilson',
status: 'confirmed',
checkInTime: '15:55',
),
CoverageWorker(
name: 'Mike Ross',
status: 'confirmed',
checkInTime: '16:00',
),
CoverageWorker(
name: 'Jane Doe',
status: 'confirmed',
checkInTime: null,
),
CoverageWorker(
name: 'John Smith',
status: 'late',
checkInTime: null,
),
],
),
CoverageShift(
id: '2',
title: 'Bartender',
location: 'Lobby Bar',
startTime: '17:00',
workersNeeded: 4,
date: date,
workers: const <CoverageWorker>[
CoverageWorker(
name: 'Emily Blunt',
status: 'confirmed',
checkInTime: '16:45',
),
CoverageWorker(
name: 'Chris Evans',
status: 'confirmed',
checkInTime: '16:50',
),
CoverageWorker(
name: 'Tom Holland',
status: 'confirmed',
checkInTime: null,
),
],
),
];
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
print(
'Coverage: request businessId=$businessId dayStart=${start.toIso8601String()} dayEnd=${end.toIso8601String()}',
);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
final fdc.QueryResult<
dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
await _dataConnect
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
print(
'Coverage: ${date.toIso8601String()} staffsApplications=${applicationsResult.data.applications.length}',
);
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
}
/// Fetches coverage statistics for a specific date.
@@ -120,4 +98,128 @@ class CoverageRepositoryImpl implements CoverageRepository {
late: late,
);
}
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final int seconds = dateTime.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds =
(dateTime.millisecondsSinceEpoch % 1000) * 1000000;
return fdc.Timestamp(nanoseconds, seconds);
}
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 ?? '',
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 ?? '',
startTime: '00:00',
workersNeeded: 0,
date: 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();
}
String _mapWorkerStatus(
dc.EnumValue<dc.ApplicationStatus> status,
) {
if (status is dc.Known<dc.ApplicationStatus>) {
switch (status.value) {
case dc.ApplicationStatus.LATE:
return 'late';
case dc.ApplicationStatus.CHECKED_IN:
case dc.ApplicationStatus.CHECKED_OUT:
case dc.ApplicationStatus.ACCEPTED:
case dc.ApplicationStatus.CONFIRMED:
case dc.ApplicationStatus.PENDING:
case dc.ApplicationStatus.REJECTED:
case dc.ApplicationStatus.NO_SHOW:
return 'confirmed';
}
}
return 'confirmed';
}
String? _formatTime(fdc.Timestamp? timestamp) {
if (timestamp == null) {
return null;
}
final DateTime date = timestamp.toDateTime();
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

@@ -53,7 +53,7 @@ class CoverageHeader extends StatelessWidget {
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.accent,
UiColors.primary,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,

View File

@@ -17,8 +17,95 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
HomeRepositoryImpl(this._mock, this._dataConnect);
@override
Future<HomeDashboardData> getDashboardData() {
return _mock.getDashboardData();
Future<HomeDashboardData> getDashboardData() async {
final String? businessId = ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const HomeDashboardData(
weeklySpending: 0,
next7DaysSpending: 0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
);
}
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<
GetCompletedShiftsByBusinessIdData,
GetCompletedShiftsByBusinessIdVariables> completedResult =
await _dataConnect
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _toTimestamp(weekRangeStart),
dateTo: _toTimestamp(weekRangeEnd),
)
.execute();
print(
'Home spending: businessId=$businessId dateFrom=${weekRangeStart.toIso8601String()} '
'dateTo=${weekRangeEnd.toIso8601String()} shifts=${completedResult.data.shifts.length}',
);
double weeklySpending = 0.0;
double next7DaysSpending = 0.0;
int weeklyShifts = 0;
int next7DaysScheduled = 0;
for (final 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<
ListShiftRolesByBusinessAndDateRangeData,
ListShiftRolesByBusinessAndDateRangeVariables> result =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
int totalNeeded = 0;
int totalFilled = 0;
for (final 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,
);
}
@override

View File

@@ -21,10 +21,16 @@ class ClientHomeState extends Equatable {
this.widgetOrder = const <String>[
'actions',
'reorder',
'coverage',
'spending',
'liveActivity',
],
this.widgetVisibility = const <String, bool>{
'actions': true,
'reorder': true,
'coverage': true,
'spending': true,
'liveActivity': true,
},
this.isEditMode = false,
this.errorMessage,

View File

@@ -94,7 +94,9 @@ class DashboardWidgetBuilder extends StatelessWidget {
: 0,
);
case 'liveActivity':
return LiveActivityWidget(onViewAllPressed: () {});
return LiveActivityWidget(
onViewAllPressed: () => Modular.to.navigate('/client-main/coverage/'),
);
default:
return const SizedBox.shrink();
}

View File

@@ -1,44 +1,107 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'coverage_dashboard.dart';
/// A widget that displays live activity information.
class LiveActivityWidget extends StatelessWidget {
class LiveActivityWidget extends StatefulWidget {
/// Callback when "View all" is pressed.
final VoidCallback onViewAllPressed;
/// Creates a [LiveActivityWidget].
const LiveActivityWidget({super.key, required this.onViewAllPressed});
@override
State<LiveActivityWidget> createState() => _LiveActivityWidgetState();
}
class _LiveActivityWidgetState extends State<LiveActivityWidget> {
late final Future<_LiveActivityData> _liveActivityFuture =
_loadLiveActivity();
Future<_LiveActivityData> _loadLiveActivity() async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return _LiveActivityData.empty();
}
final DateTime now = DateTime.now();
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.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> result =
await dc.ExampleConnector.instance
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
if (result.data.applications.isEmpty) {
return _LiveActivityData.empty();
}
final Map<String, _LiveShiftAggregate> aggregates =
<String, _LiveShiftAggregate>{};
for (final dc.ListStaffsApplicationsByBusinessForDayApplications app
in result.data.applications) {
final String key = '${app.shiftId}:${app.roleId}';
final _LiveShiftAggregate aggregate = aggregates[key] ??
_LiveShiftAggregate(
workersNeeded: app.shiftRole.count,
assigned: app.shiftRole.assigned ?? 0,
cost: app.shiftRole.shift.cost ?? 0,
);
aggregates[key] = aggregate;
}
int totalNeeded = 0;
int totalAssigned = 0;
double totalCost = 0;
for (final _LiveShiftAggregate aggregate in aggregates.values) {
totalNeeded += aggregate.workersNeeded;
totalAssigned += aggregate.assigned;
totalCost += aggregate.cost;
}
int lateCount = 0;
int checkedInCount = 0;
for (final dc.ListStaffsApplicationsByBusinessForDayApplications app
in result.data.applications) {
if (app.checkInTime != null) {
checkedInCount += 1;
}
if (app.status is dc.Known<dc.ApplicationStatus> &&
(app.status as dc.Known<dc.ApplicationStatus>).value ==
dc.ApplicationStatus.LATE) {
lateCount += 1;
}
}
return _LiveActivityData(
totalNeeded: totalNeeded,
totalAssigned: totalAssigned,
totalCost: totalCost,
checkedInCount: checkedInCount,
lateCount: lateCount,
);
}
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final int seconds = dateTime.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds =
(dateTime.millisecondsSinceEpoch % 1000) * 1000000;
return fdc.Timestamp(nanoseconds, seconds);
}
@override
Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home;
// Mock data
final List<Map<String, Object>> shifts = <Map<String, Object>>[
<String, Object>{
'workersNeeded': 5,
'filled': 4,
'hourlyRate': 20.0,
'status': 'OPEN',
'date': DateTime.now().toIso8601String().split('T')[0],
},
<String, Object>{
'workersNeeded': 5,
'filled': 5,
'hourlyRate': 22.0,
'status': 'FILLED',
'date': DateTime.now().toIso8601String().split('T')[0],
},
];
final List<Map<String, String>> applications = <Map<String, String>>[
<String, String>{'status': 'CONFIRMED', 'checkInTime': '09:00'},
<String, String>{'status': 'CONFIRMED', 'checkInTime': '09:05'},
<String, String>{'status': 'CONFIRMED'},
<String, String>{'status': 'LATE'},
];
return Column(
children: <Widget>[
Row(
@@ -51,7 +114,7 @@ class LiveActivityWidget extends StatelessWidget {
),
),
GestureDetector(
onTap: onViewAllPressed,
onTap: widget.onViewAllPressed,
child: Text(
i18n.dashboard.view_all,
style: UiTypography.footnote1m.copyWith(
@@ -62,8 +125,82 @@ class LiveActivityWidget extends StatelessWidget {
],
),
const SizedBox(height: UiConstants.space2),
CoverageDashboard(shifts: shifts, applications: applications),
FutureBuilder<_LiveActivityData>(
future: _liveActivityFuture,
builder: (BuildContext context,
AsyncSnapshot<_LiveActivityData> snapshot) {
final _LiveActivityData data =
snapshot.data ?? _LiveActivityData.empty();
final List<Map<String, Object>> shifts =
<Map<String, Object>>[
<String, Object>{
'workersNeeded': data.totalNeeded,
'filled': data.totalAssigned,
'hourlyRate': data.totalAssigned == 0
? 0.0
: data.totalCost / data.totalAssigned,
'status': 'OPEN',
'date': DateTime.now().toIso8601String().split('T')[0],
},
];
final List<Map<String, Object?>> applications =
<Map<String, Object?>>[];
for (int i = 0; i < data.checkedInCount; i += 1) {
applications.add(
<String, Object?>{
'status': 'CONFIRMED',
'checkInTime': '09:00',
},
);
}
for (int i = 0; i < data.lateCount; i += 1) {
applications.add(<String, Object?>{'status': 'LATE'});
}
return CoverageDashboard(
shifts: shifts,
applications: applications,
);
},
),
],
);
}
}
class _LiveActivityData {
const _LiveActivityData({
required this.totalNeeded,
required this.totalAssigned,
required this.totalCost,
required this.checkedInCount,
required this.lateCount,
});
final int totalNeeded;
final int totalAssigned;
final double totalCost;
final int checkedInCount;
final int lateCount;
factory _LiveActivityData.empty() {
return const _LiveActivityData(
totalNeeded: 0,
totalAssigned: 0,
totalCost: 0,
checkedInCount: 0,
lateCount: 0,
);
}
}
class _LiveShiftAggregate {
_LiveShiftAggregate({
required this.workersNeeded,
required this.assigned,
required this.cost,
});
final int workersNeeded;
final int assigned;
final double cost;
}