feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,12 +1,11 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'src/data/repositories_impl/home_repository_impl.dart';
import 'src/domain/repositories/home_repository_interface.dart';
import 'src/domain/usecases/get_dashboard_data_usecase.dart';
import 'src/domain/usecases/get_recent_reorders_usecase.dart';
import 'src/domain/usecases/get_user_session_data_usecase.dart';
import 'src/presentation/blocs/client_home_bloc.dart';
import 'src/presentation/pages/client_home_page.dart';
@@ -14,24 +13,34 @@ export 'src/presentation/pages/client_home_page.dart';
/// A [Module] for the client home feature.
///
/// This module configures the dependencies for the client home feature,
/// including repositories, use cases, and BLoCs.
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
/// use cases, and BLoCs for the client dashboard.
class ClientHomeModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<HomeRepositoryInterface>(HomeRepositoryImpl.new);
i.addLazySingleton<HomeRepositoryInterface>(
() => HomeRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(GetDashboardDataUseCase.new);
i.addLazySingleton(GetRecentReordersUseCase.new);
i.addLazySingleton(GetUserSessionDataUseCase.new);
i.addLazySingleton(
() => GetDashboardDataUseCase(i.get<HomeRepositoryInterface>()),
);
i.addLazySingleton(
() => GetRecentReordersUseCase(i.get<HomeRepositoryInterface>()),
);
// BLoCs
i.add<ClientHomeBloc>(ClientHomeBloc.new);
i.add<ClientHomeBloc>(
() => ClientHomeBloc(
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
getRecentReordersUseCase: i.get<GetRecentReordersUseCase>(),
),
);
}
@override

View File

@@ -1,198 +1,37 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/home_repository_interface.dart';
/// Implementation of [HomeRepositoryInterface] that directly interacts with the Data Connect SDK.
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// V2 API implementation of [HomeRepositoryInterface].
///
/// Fetches client dashboard data from `GET /client/dashboard` and recent
/// reorders from `GET /client/reorders`.
class HomeRepositoryImpl implements HomeRepositoryInterface {
HomeRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
/// Creates a [HomeRepositoryImpl].
HomeRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final dc.DataConnectService _service;
/// The API service used for network requests.
final BaseApiService _apiService;
@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 = monday;
final DateTime weekRangeEnd = monday.add(
const Duration(days: 13, hours: 23, minutes: 59, seconds: 59),
);
final 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 = _service.toDateTime(shift.date);
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 = start.add(
const Duration(hours: 23, minutes: 59, seconds: 59),
);
final 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,
);
});
Future<ClientDashboard> getDashboard() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientDashboard);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return ClientDashboard.fromJson(data);
}
@override
Future<UserSessionData> getUserSessionData() async {
return await _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
businessResult = await _service.connector
.getBusinessById(id: businessId)
.execute();
final dc.GetBusinessByIdBusiness? b = businessResult.data.business;
if (b == null) {
throw Exception('Business data not found for ID: $businessId');
}
final dc.ClientSession updatedSession = dc.ClientSession(
business: dc.ClientBusinessSession(
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: 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 QueryResult<
dc.ListCompletedOrdersByBusinessAndDateRangeData,
dc.ListCompletedOrdersByBusinessAndDateRangeVariables
>
result = await _service.connector
.listCompletedOrdersByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(now),
)
.execute();
return result.data.orders.map((
dc.ListCompletedOrdersByBusinessAndDateRangeOrders order,
) {
final String title =
order.eventName ??
(order.shifts_on_order.isNotEmpty
? order.shifts_on_order[0].title
: 'Order');
final String location = order.shifts_on_order.isNotEmpty
? (order.shifts_on_order[0].location ??
order.shifts_on_order[0].locationAddress ??
'')
: '';
int totalWorkers = 0;
double totalHours = 0;
double totalRate = 0;
int roleCount = 0;
for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrder
shift
in order.shifts_on_order) {
for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrderShiftRolesOnShift
role
in shift.shiftRoles_on_shift) {
totalWorkers += role.count;
totalHours += role.hours ?? 0;
totalRate += role.role.costPerHour;
roleCount++;
}
}
return ReorderItem(
orderId: order.id,
title: title,
location: location,
totalCost: order.total ?? 0.0,
workers: totalWorkers,
type: order.orderType.stringValue,
hourlyRate: roleCount > 0 ? totalRate / roleCount : 0.0,
hours: totalHours,
);
}).toList();
});
Future<List<RecentOrder>> getRecentReorders() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientReorders);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
final List<dynamic> items = body['items'] as List<dynamic>;
return items
.map((dynamic json) =>
RecentOrder.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -1,31 +1,15 @@
import 'package:krow_domain/krow_domain.dart';
/// User session data for the home page.
class UserSessionData {
/// Creates a [UserSessionData].
const UserSessionData({
required this.businessName,
this.photoUrl,
});
/// The business name of the logged-in user.
final String businessName;
/// The photo URL of the logged-in user (optional).
final String? photoUrl;
}
/// Interface for the Client Home repository.
///
/// This repository is responsible for providing data required for the
/// client home screen dashboard.
/// Provides data required for the client home screen dashboard
/// via the V2 REST API.
abstract interface class HomeRepositoryInterface {
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
Future<HomeDashboardData> getDashboardData();
/// Fetches the [ClientDashboard] containing aggregated dashboard metrics,
/// user name, and business info from `GET /client/dashboard`.
Future<ClientDashboard> getDashboard();
/// Fetches the user's session data (business name and photo).
Future<UserSessionData> getUserSessionData();
/// Fetches recently completed shift roles for reorder suggestions.
Future<List<ReorderItem>> getRecentReorders();
/// Fetches recent completed orders for reorder suggestions
/// from `GET /client/reorders`.
Future<List<RecentOrder>> getRecentReorders();
}

View File

@@ -1,19 +1,21 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/home_repository_interface.dart';
/// Use case to fetch dashboard data for the client home screen.
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// Use case to fetch the client dashboard from the V2 API.
///
/// This use case coordinates with the [HomeRepositoryInterface] to retrieve
/// the [HomeDashboardData] required for the dashboard display.
class GetDashboardDataUseCase implements NoInputUseCase<HomeDashboardData> {
/// Returns a [ClientDashboard] containing spending, coverage,
/// live-activity metrics and user/business info.
class GetDashboardDataUseCase implements NoInputUseCase<ClientDashboard> {
/// Creates a [GetDashboardDataUseCase].
GetDashboardDataUseCase(this._repository);
/// The repository providing dashboard data.
final HomeRepositoryInterface _repository;
@override
Future<HomeDashboardData> call() {
return _repository.getDashboardData();
Future<ClientDashboard> call() {
return _repository.getDashboard();
}
}

View File

@@ -1,16 +1,20 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/home_repository_interface.dart';
/// Use case to fetch recent completed shift roles for reorder suggestions.
class GetRecentReordersUseCase implements NoInputUseCase<List<ReorderItem>> {
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// Use case to fetch recent completed orders for reorder suggestions.
///
/// Returns a list of [RecentOrder] from the V2 API.
class GetRecentReordersUseCase implements NoInputUseCase<List<RecentOrder>> {
/// Creates a [GetRecentReordersUseCase].
GetRecentReordersUseCase(this._repository);
/// The repository providing reorder data.
final HomeRepositoryInterface _repository;
@override
Future<List<ReorderItem>> call() {
Future<List<RecentOrder>> call() {
return _repository.getRecentReorders();
}
}

View File

@@ -1,16 +0,0 @@
import '../repositories/home_repository_interface.dart';
/// Use case for retrieving user session data.
///
/// Returns the user's business name and photo URL for display in the header.
class GetUserSessionDataUseCase {
/// Creates a [GetUserSessionDataUseCase].
GetUserSessionDataUseCase(this._repository);
final HomeRepositoryInterface _repository;
/// Executes the use case to get session data.
Future<UserSessionData> call() {
return _repository.getUserSessionData();
}
}

View File

@@ -1,24 +1,27 @@
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_dashboard_data_usecase.dart';
import '../../domain/usecases/get_recent_reorders_usecase.dart';
import '../../domain/usecases/get_user_session_data_usecase.dart';
import 'client_home_event.dart';
import 'client_home_state.dart';
/// BLoC responsible for managing the state and business logic of the client home dashboard.
import 'package:client_home/src/domain/usecases/get_dashboard_data_usecase.dart';
import 'package:client_home/src/domain/usecases/get_recent_reorders_usecase.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
/// BLoC responsible for managing the client home dashboard state.
///
/// Fetches the [ClientDashboard] and recent reorders from the V2 API
/// and exposes layout-editing capabilities (reorder, toggle visibility).
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
with BlocErrorHandler<ClientHomeState>, SafeBloc<ClientHomeEvent, ClientHomeState> {
with
BlocErrorHandler<ClientHomeState>,
SafeBloc<ClientHomeEvent, ClientHomeState> {
/// Creates a [ClientHomeBloc].
ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase,
required GetRecentReordersUseCase getRecentReordersUseCase,
required GetUserSessionDataUseCase getUserSessionDataUseCase,
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getRecentReordersUseCase = getRecentReordersUseCase,
_getUserSessionDataUseCase = getUserSessionDataUseCase,
super(const ClientHomeState()) {
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getRecentReordersUseCase = getRecentReordersUseCase,
super(const ClientHomeState()) {
on<ClientHomeStarted>(_onStarted);
on<ClientHomeEditModeToggled>(_onEditModeToggled);
on<ClientHomeWidgetVisibilityToggled>(_onWidgetVisibilityToggled);
@@ -27,9 +30,12 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
add(ClientHomeStarted());
}
/// Use case that fetches the client dashboard.
final GetDashboardDataUseCase _getDashboardDataUseCase;
/// Use case that fetches recent reorders.
final GetRecentReordersUseCase _getRecentReordersUseCase;
final GetUserSessionDataUseCase _getUserSessionDataUseCase;
Future<void> _onStarted(
ClientHomeStarted event,
@@ -39,20 +45,15 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
await handleError(
emit: emit.call,
action: () async {
// Get session data
final UserSessionData sessionData = await _getUserSessionDataUseCase();
// Get dashboard data
final HomeDashboardData data = await _getDashboardDataUseCase();
final List<ReorderItem> reorderItems = await _getRecentReordersUseCase();
final ClientDashboard dashboard = await _getDashboardDataUseCase();
final List<RecentOrder> reorderItems =
await _getRecentReordersUseCase();
emit(
state.copyWith(
status: ClientHomeStatus.success,
dashboardData: data,
dashboard: dashboard,
reorderItems: reorderItems,
businessName: sessionData.businessName,
photoUrl: sessionData.photoUrl,
),
);
},
@@ -121,4 +122,3 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
);
}
}

View File

@@ -2,11 +2,23 @@ import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the client home dashboard.
enum ClientHomeStatus { initial, loading, success, error }
enum ClientHomeStatus {
/// Initial state before any data is loaded.
initial,
/// Data is being fetched.
loading,
/// Data was fetched successfully.
success,
/// An error occurred.
error,
}
/// Represents the state of the client home dashboard.
class ClientHomeState extends Equatable {
/// Creates a [ClientHomeState].
const ClientHomeState({
this.status = ClientHomeStatus.initial,
this.widgetOrder = const <String>[
@@ -25,38 +37,46 @@ class ClientHomeState extends Equatable {
},
this.isEditMode = false,
this.errorMessage,
this.dashboardData = const HomeDashboardData(
weeklySpending: 0.0,
next7DaysSpending: 0.0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
),
this.reorderItems = const <ReorderItem>[],
this.businessName = 'Your Company',
this.photoUrl,
this.dashboard,
this.reorderItems = const <RecentOrder>[],
});
final ClientHomeStatus status;
final List<String> widgetOrder;
final Map<String, bool> widgetVisibility;
final bool isEditMode;
final String? errorMessage;
final HomeDashboardData dashboardData;
final List<ReorderItem> reorderItems;
final String businessName;
final String? photoUrl;
/// The current loading status.
final ClientHomeStatus status;
/// Ordered list of widget identifiers for the dashboard layout.
final List<String> widgetOrder;
/// Visibility map keyed by widget identifier.
final Map<String, bool> widgetVisibility;
/// Whether the dashboard is in edit/customise mode.
final bool isEditMode;
/// Error key for translation when [status] is [ClientHomeStatus.error].
final String? errorMessage;
/// The V2 client dashboard data (null until loaded).
final ClientDashboard? dashboard;
/// Recent orders available for quick reorder.
final List<RecentOrder> reorderItems;
/// The business name from the dashboard, with a safe fallback.
String get businessName => dashboard?.businessName ?? 'Your Company';
/// The user display name from the dashboard.
String get userName => dashboard?.userName ?? '';
/// Creates a copy of this state with the given fields replaced.
ClientHomeState copyWith({
ClientHomeStatus? status,
List<String>? widgetOrder,
Map<String, bool>? widgetVisibility,
bool? isEditMode,
String? errorMessage,
HomeDashboardData? dashboardData,
List<ReorderItem>? reorderItems,
String? businessName,
String? photoUrl,
ClientDashboard? dashboard,
List<RecentOrder>? reorderItems,
}) {
return ClientHomeState(
status: status ?? this.status,
@@ -64,23 +84,19 @@ class ClientHomeState extends Equatable {
widgetVisibility: widgetVisibility ?? this.widgetVisibility,
isEditMode: isEditMode ?? this.isEditMode,
errorMessage: errorMessage ?? this.errorMessage,
dashboardData: dashboardData ?? this.dashboardData,
dashboard: dashboard ?? this.dashboard,
reorderItems: reorderItems ?? this.reorderItems,
businessName: businessName ?? this.businessName,
photoUrl: photoUrl ?? this.photoUrl,
);
}
@override
List<Object?> get props => <Object?>[
status,
widgetOrder,
widgetVisibility,
isEditMode,
errorMessage,
dashboardData,
reorderItems,
businessName,
photoUrl,
];
status,
widgetOrder,
widgetVisibility,
isEditMode,
errorMessage,
dashboard,
reorderItems,
];
}

View File

@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_home_bloc.dart';
import '../widgets/client_home_body.dart';
import '../widgets/client_home_edit_banner.dart';
import '../widgets/client_home_header.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/widgets/client_home_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_edit_banner.dart';
import 'package:client_home/src/presentation/widgets/client_home_header.dart';
/// The main Home page for client users.
///

View File

@@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_state.dart';
import 'client_home_edit_mode_body.dart';
import 'client_home_error_state.dart';
import 'client_home_normal_mode_body.dart';
import 'client_home_page_skeleton.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/client_home_edit_mode_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_error_state.dart';
import 'package:client_home/src/presentation/widgets/client_home_normal_mode_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_page_skeleton.dart';
/// Main body widget for the client home page.
///

View File

@@ -1,9 +1,9 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
/// A banner displayed when edit mode is active.
///

View File

@@ -2,10 +2,10 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'dashboard_widget_builder.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
///

View File

@@ -3,9 +3,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
/// Widget that displays an error state for the client home page.
///

View File

@@ -3,23 +3,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'header_icon_button.dart';
import 'client_home_header_skeleton.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/header_icon_button.dart';
import 'package:client_home/src/presentation/widgets/client_home_header_skeleton.dart';
/// The header section of the client home page.
///
/// Displays the user's business name, avatar, and action buttons
/// (edit mode, notifications, settings).
/// (edit mode, settings).
class ClientHomeHeader extends StatelessWidget {
/// Creates a [ClientHomeHeader].
const ClientHomeHeader({
required this.i18n,
super.key,
});
/// The internationalization object for localized strings.
final dynamic i18n;
@@ -33,7 +34,6 @@ class ClientHomeHeader extends StatelessWidget {
}
final String businessName = state.businessName;
final String? photoUrl = state.photoUrl;
final String avatarLetter = businessName.trim().isNotEmpty
? businessName.trim()[0].toUpperCase()
: 'C';
@@ -62,18 +62,12 @@ class ClientHomeHeader extends StatelessWidget {
),
child: CircleAvatar(
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
backgroundImage:
photoUrl != null && photoUrl.isNotEmpty
? NetworkImage(photoUrl)
: null,
child: photoUrl != null && photoUrl.isNotEmpty
? null
: Text(
avatarLetter,
style: UiTypography.body2b.copyWith(
color: UiColors.primary,
),
),
child: Text(
avatarLetter,
style: UiTypography.body2b.copyWith(
color: UiColors.primary,
),
),
),
),
const SizedBox(width: UiConstants.space3),

View File

@@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../blocs/client_home_state.dart';
import 'dashboard_widget_builder.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
/// Widget that displays the home dashboard in normal mode.
///

View File

@@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'shift_order_form_sheet.dart';
/// Helper class for showing modal sheets in the client home feature.
class ClientHomeSheets {
/// Shows the shift order form bottom sheet.
///
/// Optionally accepts [initialData] to pre-populate the form for reordering.
/// Calls [onSubmit] when the user submits the form successfully.
static void showOrderFormSheet(
BuildContext context,
Map<String, dynamic>? initialData, {
required void Function(Map<String, dynamic>) onSubmit,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return ShiftOrderFormSheet(
initialData: initialData,
onSubmit: onSubmit,
);
},
);
}
}

View File

@@ -1,217 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A dashboard widget that displays today's coverage status.
class CoverageDashboard extends StatelessWidget {
/// Creates a [CoverageDashboard].
const CoverageDashboard({
super.key,
required this.shifts,
required this.applications,
});
/// The list of shifts for today.
final List<dynamic> shifts;
/// The list of applications for today's shifts.
final List<dynamic> applications;
@override
Widget build(BuildContext context) {
int totalNeeded = 0;
int totalConfirmed = 0;
double todayCost = 0;
for (final dynamic s in shifts) {
final int needed =
(s as Map<String, dynamic>)['workersNeeded'] as int? ?? 0;
final int confirmed = s['filled'] as int? ?? 0;
final double rate = s['hourlyRate'] as double? ?? 0.0;
final double hours = s['hours'] as double? ?? 0.0;
totalNeeded += needed;
totalConfirmed += confirmed;
todayCost += rate * hours;
}
final int coveragePercent = totalNeeded > 0
? ((totalConfirmed / totalNeeded) * 100).round()
: 100;
final int unfilledPositions = totalNeeded - totalConfirmed;
final int checkedInCount = applications
.where(
(dynamic a) => (a as Map<String, dynamic>)['checkInTime'] != null,
)
.length;
final int lateWorkersCount = applications
.where((dynamic a) => (a as Map<String, dynamic>)['status'] == 'LATE')
.length;
final bool isCoverageGood = coveragePercent >= 90;
final Color coverageBadgeColor = isCoverageGood
? UiColors.tagSuccess
: UiColors.tagPending;
final Color coverageTextColor = isCoverageGood
? UiColors.textSuccess
: UiColors.textWarning;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text("Today's Status", style: UiTypography.body1m.textSecondary),
if (totalNeeded > 0 || totalConfirmed > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2.0,
),
decoration: BoxDecoration(
color: coverageBadgeColor,
borderRadius: UiConstants.radiusMd,
),
child: Text(
'$coveragePercent% Covered',
style: UiTypography.footnote1b.copyWith(
color: coverageTextColor,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Unfilled Today',
value: '$unfilledPositions',
icon: UiIcons.warning,
isWarning: unfilledPositions > 0,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: 'Running Late',
value: '$lateWorkersCount',
icon: UiIcons.error,
isError: true,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Checked In',
value: '$checkedInCount/$totalNeeded',
icon: UiIcons.success,
isInfo: true,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: "Today's Cost",
value: '\$${todayCost.round()}',
icon: UiIcons.dollar,
isInfo: true,
),
],
),
),
],
),
],
),
);
}
}
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.label,
required this.value,
required this.icon,
this.isWarning = false,
this.isError = false,
this.isInfo = false,
});
final String label;
final String value;
final IconData icon;
final bool isWarning;
final bool isError;
final bool isInfo;
@override
Widget build(BuildContext context) {
Color bg = UiColors.bgSecondary;
Color border = UiColors.border;
Color iconColor = UiColors.iconSecondary;
Color textColor = UiColors.textPrimary;
if (isWarning) {
bg = UiColors.tagPending.withAlpha(80);
border = UiColors.textWarning.withAlpha(80);
iconColor = UiColors.textWarning;
textColor = UiColors.textWarning;
} else if (isError) {
bg = UiColors.tagError.withAlpha(80);
border = UiColors.borderError.withAlpha(80);
iconColor = UiColors.textError;
textColor = UiColors.textError;
} else if (isInfo) {
bg = UiColors.tagInProgress.withAlpha(80);
border = UiColors.primary.withValues(alpha: 0.2);
iconColor = UiColors.primary;
textColor = UiColors.primary;
}
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: border),
borderRadius: UiConstants.radiusMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
label,
style: UiTypography.footnote1m.copyWith(
color: textColor.withValues(alpha: 0.8),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(
value,
style: UiTypography.headline3m.copyWith(color: textColor),
),
],
),
);
}
}

View File

@@ -2,18 +2,20 @@ import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/client_home_state.dart';
import '../widgets/actions_widget.dart';
import '../widgets/coverage_widget.dart';
import '../widgets/draggable_widget_wrapper.dart';
import '../widgets/live_activity_widget.dart';
import '../widgets/reorder_widget.dart';
import '../widgets/spending_widget.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/actions_widget.dart';
import 'package:client_home/src/presentation/widgets/coverage_widget.dart';
import 'package:client_home/src/presentation/widgets/draggable_widget_wrapper.dart';
import 'package:client_home/src/presentation/widgets/live_activity_widget.dart';
import 'package:client_home/src/presentation/widgets/reorder_widget.dart';
import 'package:client_home/src/presentation/widgets/spending_widget.dart';
/// A widget that builds dashboard content based on widget ID.
///
/// This widget encapsulates the logic for rendering different dashboard
/// widgets based on their unique identifiers and current state.
/// Renders different dashboard sections depending on their unique identifier
/// and the current [ClientHomeState].
class DashboardWidgetBuilder extends StatelessWidget {
/// Creates a [DashboardWidgetBuilder].
const DashboardWidgetBuilder({
@@ -55,11 +57,16 @@ class DashboardWidgetBuilder extends StatelessWidget {
}
/// Builds the actual widget content based on the widget ID.
Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) {
Widget _buildWidgetContent(
BuildContext context,
TranslationsClientHomeWidgetsEn i18n,
) {
final String title = _getWidgetTitle(i18n);
// Only show subtitle in normal mode
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
final ClientDashboard? dashboard = state.dashboard;
switch (id) {
case 'actions':
return ActionsWidget(title: title, subtitle: subtitle);
@@ -71,28 +78,32 @@ class DashboardWidgetBuilder extends StatelessWidget {
);
case 'spending':
return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
weeklySpendCents: dashboard?.spending.weeklySpendCents ?? 0,
projectedNext7DaysCents:
dashboard?.spending.projectedNext7DaysCents ?? 0,
title: title,
subtitle: subtitle,
);
case 'coverage':
final CoverageMetrics? coverage = dashboard?.coverage;
final int needed = coverage?.neededWorkersToday ?? 0;
final int filled = coverage?.filledWorkersToday ?? 0;
return CoverageWidget(
totalNeeded: state.dashboardData.totalNeeded,
totalConfirmed: state.dashboardData.totalFilled,
coveragePercent: state.dashboardData.totalNeeded > 0
? ((state.dashboardData.totalFilled /
state.dashboardData.totalNeeded) *
100)
.toInt()
: 0,
totalNeeded: needed,
totalConfirmed: filled,
coveragePercent: needed > 0 ? ((filled / needed) * 100).toInt() : 0,
title: title,
subtitle: subtitle,
);
case 'liveActivity':
return LiveActivityWidget(
metrics: dashboard?.liveActivity ??
const LiveActivityMetrics(
lateWorkersToday: 0,
checkedInWorkersToday: 0,
averageShiftCostCents: 0,
),
coverageNeeded: dashboard?.coverage.neededWorkersToday ?? 0,
onViewAllPressed: () => Modular.to.toClientCoverage(),
title: title,
subtitle: subtitle,
@@ -106,20 +117,21 @@ class DashboardWidgetBuilder extends StatelessWidget {
String _getWidgetTitle(dynamic i18n) {
switch (id) {
case 'actions':
return i18n.actions;
return i18n.actions as String;
case 'reorder':
return i18n.reorder;
return i18n.reorder as String;
case 'coverage':
return i18n.coverage;
return i18n.coverage as String;
case 'spending':
return i18n.spending;
return i18n.spending as String;
case 'liveActivity':
return i18n.live_activity;
return i18n.live_activity as String;
default:
return '';
}
}
/// Returns the subtitle for the widget based on its ID.
String _getWidgetSubtitle(String id) {
switch (id) {
case 'actions':

View File

@@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
/// A wrapper for dashboard widgets in edit mode.
///

View File

@@ -1,21 +1,31 @@
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 'package:krow_domain/krow_domain.dart';
import 'coverage_dashboard.dart';
import 'section_layout.dart';
/// A widget that displays live activity information.
class LiveActivityWidget extends StatefulWidget {
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that displays live activity metrics for today.
///
/// Renders checked-in count, late workers, and average shift cost
/// from the [LiveActivityMetrics] provided by the V2 dashboard endpoint.
class LiveActivityWidget extends StatelessWidget {
/// Creates a [LiveActivityWidget].
const LiveActivityWidget({
super.key,
required this.metrics,
required this.coverageNeeded,
required this.onViewAllPressed,
this.title,
this.subtitle
this.subtitle,
});
/// Live activity metrics from the V2 dashboard.
final LiveActivityMetrics metrics;
/// Workers needed today (from coverage metrics) for the checked-in ratio.
final int coverageNeeded;
/// Callback when "View all" is pressed.
final VoidCallback onViewAllPressed;
@@ -25,159 +35,180 @@ class LiveActivityWidget extends StatefulWidget {
/// Optional subtitle for the section.
final String? subtitle;
@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.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await dc.ExampleConnector.instance
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> result =
await dc.ExampleConnector.instance
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
if (shiftRolesResult.data.shiftRoles.isEmpty &&
result.data.applications.isEmpty) {
return _LiveActivityData.empty();
}
int totalNeeded = 0;
double totalCost = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in shiftRolesResult.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalCost += shiftRole.totalValue ?? 0;
}
final int totalAssigned = result.data.applications.length;
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 DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds =
(utc.millisecondsSinceEpoch % 1000) * 1000000;
return fdc.Timestamp(nanoseconds, seconds);
}
@override
Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home;
final int checkedIn = metrics.checkedInWorkersToday;
final int late_ = metrics.lateWorkersToday;
final String avgCostDisplay =
'\$${(metrics.averageShiftCostCents / 100).toStringAsFixed(0)}';
final int coveragePercent =
coverageNeeded > 0 ? ((checkedIn / coverageNeeded) * 100).round() : 100;
final bool isCoverageGood = coveragePercent >= 90;
final Color coverageBadgeColor =
isCoverageGood ? UiColors.tagSuccess : UiColors.tagPending;
final Color coverageTextColor =
isCoverageGood ? UiColors.textSuccess : UiColors.textWarning;
return SectionLayout(
title: widget.title,
subtitle: widget.subtitle,
title: title,
subtitle: subtitle,
action: i18n.dashboard.view_all,
onAction: widget.onViewAllPressed,
child: 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': 1.0,
'hours': data.totalCost,
'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,
);
},
onAction: onViewAllPressed,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
// ASSUMPTION: Reusing hardcoded string from previous
// CoverageDashboard widget — a future localization pass should
// add a dedicated i18n key.
Text(
"Today's Status",
style: UiTypography.body1m.textSecondary,
),
if (coverageNeeded > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2.0,
),
decoration: BoxDecoration(
color: coverageBadgeColor,
borderRadius: UiConstants.radiusMd,
),
child: Text(
i18n.dashboard.percent_covered(percent: coveragePercent),
style: UiTypography.footnote1b.copyWith(
color: coverageTextColor,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
children: <Widget>[
// ASSUMPTION: Reusing hardcoded strings from previous
// CoverageDashboard widget.
_StatusCard(
label: 'Running Late',
value: '$late_',
icon: UiIcons.error,
isError: true,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: "Today's Cost",
value: avgCostDisplay,
icon: UiIcons.dollar,
isInfo: true,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Checked In',
value: '$checkedIn/$coverageNeeded',
icon: UiIcons.success,
isInfo: true,
),
],
),
),
],
),
],
),
),
);
}
}
class _LiveActivityData {
factory _LiveActivityData.empty() {
return const _LiveActivityData(
totalNeeded: 0,
totalAssigned: 0,
totalCost: 0,
checkedInCount: 0,
lateCount: 0,
);
}
const _LiveActivityData({
required this.totalNeeded,
required this.totalAssigned,
required this.totalCost,
required this.checkedInCount,
required this.lateCount,
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.label,
required this.value,
required this.icon,
this.isError = false,
this.isInfo = false,
});
final int totalNeeded;
final int totalAssigned;
final double totalCost;
final int checkedInCount;
final int lateCount;
final String label;
final String value;
final IconData icon;
final bool isError;
final bool isInfo;
@override
Widget build(BuildContext context) {
Color bg = UiColors.bgSecondary;
Color border = UiColors.border;
Color iconColor = UiColors.iconSecondary;
Color textColor = UiColors.textPrimary;
if (isError) {
bg = UiColors.tagError.withAlpha(80);
border = UiColors.borderError.withAlpha(80);
iconColor = UiColors.textError;
textColor = UiColors.textError;
} else if (isInfo) {
bg = UiColors.tagInProgress.withAlpha(80);
border = UiColors.primary.withValues(alpha: 0.2);
iconColor = UiColors.primary;
textColor = UiColors.primary;
}
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: border),
borderRadius: UiConstants.radiusMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
label,
style: UiTypography.footnote1m.copyWith(
color: textColor.withValues(alpha: 0.8),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(
value,
style: UiTypography.headline3m.copyWith(color: textColor),
),
],
),
);
}
}

View File

@@ -5,9 +5,11 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'section_layout.dart';
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that allows clients to reorder recent shifts.
/// A widget that allows clients to reorder recent orders.
///
/// Displays a horizontal list of [RecentOrder] cards with a reorder button.
class ReorderWidget extends StatelessWidget {
/// Creates a [ReorderWidget].
const ReorderWidget({
@@ -18,7 +20,7 @@ class ReorderWidget extends StatelessWidget {
});
/// Recent completed orders for reorder.
final List<ReorderItem> orders;
final List<RecentOrder> orders;
/// Optional title for the section.
final String? title;
@@ -34,21 +36,18 @@ class ReorderWidget extends StatelessWidget {
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
final List<ReorderItem> recentOrders = orders;
return SectionLayout(
title: title,
subtitle: subtitle,
child: SizedBox(
height: 164,
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentOrders.length,
itemCount: orders.length,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space3),
itemBuilder: (BuildContext context, int index) {
final ReorderItem order = recentOrders[index];
final double totalCost = order.totalCost;
final RecentOrder order = orders[index];
return Container(
width: 260,
@@ -71,9 +70,7 @@ class ReorderWidget extends StatelessWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
color: UiColors.primary.withValues(
alpha: 0.1,
),
color: UiColors.primary.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
),
child: const Icon(
@@ -92,12 +89,14 @@ class ReorderWidget extends StatelessWidget {
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
if (order.hubName != null &&
order.hubName!.isNotEmpty)
Text(
order.hubName!,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
@@ -107,12 +106,11 @@ class ReorderWidget extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
// ASSUMPTION: No i18n key for 'positions' under
// reorder section — carrying forward existing
// hardcoded string pattern for this migration.
Text(
'\$${totalCost.toStringAsFixed(0)}',
style: UiTypography.body1b,
),
Text(
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
'${order.positionCount} positions',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -124,7 +122,7 @@ class ReorderWidget extends StatelessWidget {
children: <Widget>[
_Badge(
icon: UiIcons.success,
text: order.type,
text: order.orderType.value,
color: UiColors.primary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.primary,
@@ -132,7 +130,7 @@ class ReorderWidget extends StatelessWidget {
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order.workers}',
text: '${order.positionCount}',
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.textSecondary,
@@ -140,24 +138,13 @@ class ReorderWidget extends StatelessWidget {
],
),
const Spacer(),
UiButton.secondary(
size: UiButtonSize.small,
text: i18n.reorder_button,
leadingIcon: UiIcons.zap,
iconSize: 12,
fullWidth: true,
onPressed: () =>
_handleReorderPressed(context, <String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
'totalCost': order.totalCost,
}),
onPressed: () => _handleReorderPressed(order),
),
],
),
@@ -168,28 +155,27 @@ class ReorderWidget extends StatelessWidget {
);
}
void _handleReorderPressed(BuildContext context, Map<String, dynamic> data) {
// Override start date with today's date as requested
final Map<String, dynamic> populatedData = Map<String, dynamic>.from(data)
..['startDate'] = DateTime.now();
/// Navigates to the appropriate create-order form pre-populated
/// with data from the selected [order].
void _handleReorderPressed(RecentOrder order) {
final Map<String, dynamic> populatedData = <String, dynamic>{
'orderId': order.id,
'title': order.title,
'location': order.hubName ?? '',
'workers': order.positionCount,
'type': order.orderType.value,
'startDate': DateTime.now(),
};
final String? typeStr = populatedData['type']?.toString();
if (typeStr == null || typeStr.isEmpty) {
return;
}
final OrderType orderType = OrderType.fromString(typeStr);
switch (orderType) {
switch (order.orderType) {
case OrderType.recurring:
Modular.to.toCreateOrderRecurring(arguments: populatedData);
break;
case OrderType.permanent:
Modular.to.toCreateOrderPermanent(arguments: populatedData);
break;
case OrderType.oneTime:
default:
case OrderType.rapid:
case OrderType.unknown:
Modular.to.toCreateOrderOneTime(arguments: populatedData);
break;
}
}
}
@@ -202,6 +188,7 @@ class _Badge extends StatelessWidget {
required this.bg,
required this.textColor,
});
final IconData icon;
final String text;
final Color color;

View File

@@ -2,32 +2,26 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'section_layout.dart';
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that displays spending insights for the client.
///
/// All monetary values are in **cents** and converted to dollars for display.
class SpendingWidget extends StatelessWidget {
/// Creates a [SpendingWidget].
const SpendingWidget({
super.key,
required this.weeklySpending,
required this.next7DaysSpending,
required this.weeklyShifts,
required this.next7DaysScheduled,
required this.weeklySpendCents,
required this.projectedNext7DaysCents,
this.title,
this.subtitle,
});
/// The spending this week.
final double weeklySpending;
/// The spending for the next 7 days.
final double next7DaysSpending;
/// Total spend this week in cents.
final int weeklySpendCents;
/// The number of shifts this week.
final int weeklyShifts;
/// The number of scheduled shifts for next 7 days.
final int next7DaysScheduled;
/// Projected spend for the next 7 days in cents.
final int projectedNext7DaysCents;
/// Optional title for the section.
final String? title;
@@ -37,6 +31,11 @@ class SpendingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final String weeklyDisplay =
'\$${(weeklySpendCents / 100).toStringAsFixed(0)}';
final String projectedDisplay =
'\$${(projectedNext7DaysCents / 100).toStringAsFixed(0)}';
return SectionLayout(
title: title,
subtitle: subtitle,
@@ -77,19 +76,12 @@ class SpendingWidget extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${weeklySpending.toStringAsFixed(0)}',
weeklyDisplay,
style: UiTypography.headline3m.copyWith(
color: UiColors.white,
fontWeight: FontWeight.bold,
),
),
Text(
t.client_home.dashboard.spending.shifts_count(count: weeklyShifts),
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9,
),
),
],
),
),
@@ -106,19 +98,12 @@ class SpendingWidget extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${next7DaysSpending.toStringAsFixed(0)}',
projectedDisplay,
style: UiTypography.headline4m.copyWith(
color: UiColors.white,
fontWeight: FontWeight.bold,
),
),
Text(
t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled),
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9,
),
),
],
),
),

View File

@@ -14,19 +14,16 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_domain: ^0.0.1
krow_data_connect: ^0.0.1
krow_core:
path: ../../../core
firebase_data_connect: any
intl: any
dev_dependencies:
flutter_test:
sdk: flutter