feat(client_coverage): add client coverage feature with user session data use case and dashboard widgets

- Created `client_coverage` feature with necessary dependencies in `pubspec.yaml`.
- Implemented `GetUserSessionDataUseCase` for retrieving user session data.
- Developed `ClientHomeEditBanner` for edit mode instructions and reset functionality.
- Added `ClientHomeHeader` to display user information and action buttons.
- Built `DashboardWidgetBuilder` to render various dashboard widgets based on state.
- Introduced `DraggableWidgetWrapper` for managing widget visibility and drag handles in edit mode.
- Created `HeaderIconButton` for interactive header actions with optional badge support.
This commit is contained in:
Achintha Isuru
2026-01-23 16:25:01 -05:00
parent 597028436d
commit 2b331e356b
39 changed files with 3032 additions and 426 deletions

View File

@@ -18,4 +18,13 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
Future<HomeDashboardData> getDashboardData() {
return _mock.getDashboardData();
}
@override
UserSessionData getUserSessionData() {
final (businessName, photoUrl) = _mock.getUserSession();
return UserSessionData(
businessName: businessName,
photoUrl: photoUrl,
);
}
}

View File

@@ -1,5 +1,20 @@
import 'package:krow_domain/krow_domain.dart';
/// User session data for the home page.
class UserSessionData {
/// The business name of the logged-in user.
final String businessName;
/// The photo URL of the logged-in user (optional).
final String? photoUrl;
/// Creates a [UserSessionData].
const UserSessionData({
required this.businessName,
this.photoUrl,
});
}
/// Interface for the Client Home repository.
///
/// This repository is responsible for providing data required for the
@@ -7,4 +22,7 @@ import 'package:krow_domain/krow_domain.dart';
abstract interface class HomeRepositoryInterface {
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
Future<HomeDashboardData> getDashboardData();
/// Fetches the user's session data (business name and photo).
UserSessionData getUserSessionData();
}

View File

@@ -0,0 +1,16 @@
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 {
final HomeRepositoryInterface _repository;
/// Creates a [GetUserSessionDataUseCase].
GetUserSessionDataUseCase(this._repository);
/// Executes the use case to get session data.
UserSessionData call() {
return _repository.getUserSessionData();
}
}

View File

@@ -1,15 +1,20 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_dashboard_data_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.
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
final GetDashboardDataUseCase _getDashboardDataUseCase;
final GetUserSessionDataUseCase _getUserSessionDataUseCase;
ClientHomeBloc({required GetDashboardDataUseCase getDashboardDataUseCase})
: _getDashboardDataUseCase = getDashboardDataUseCase,
super(const ClientHomeState()) {
ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase,
required GetUserSessionDataUseCase getUserSessionDataUseCase,
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getUserSessionDataUseCase = getUserSessionDataUseCase,
super(const ClientHomeState()) {
on<ClientHomeStarted>(_onStarted);
on<ClientHomeEditModeToggled>(_onEditModeToggled);
on<ClientHomeWidgetVisibilityToggled>(_onWidgetVisibilityToggled);
@@ -23,9 +28,19 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
) async {
emit(state.copyWith(status: ClientHomeStatus.loading));
try {
// Get session data
final sessionData = _getUserSessionDataUseCase();
// Get dashboard data
final data = await _getDashboardDataUseCase();
emit(
state.copyWith(status: ClientHomeStatus.success, dashboardData: data),
state.copyWith(
status: ClientHomeStatus.success,
dashboardData: data,
businessName: sessionData.businessName,
photoUrl: sessionData.photoUrl,
),
);
} catch (e) {
emit(

View File

@@ -12,6 +12,8 @@ class ClientHomeState extends Equatable {
final bool isEditMode;
final String? errorMessage;
final HomeDashboardData dashboardData;
final String businessName;
final String? photoUrl;
const ClientHomeState({
this.status = ClientHomeStatus.initial,
@@ -39,6 +41,8 @@ class ClientHomeState extends Equatable {
totalNeeded: 10,
totalFilled: 8,
),
this.businessName = 'Your Company',
this.photoUrl,
});
ClientHomeState copyWith({
@@ -48,6 +52,8 @@ class ClientHomeState extends Equatable {
bool? isEditMode,
String? errorMessage,
HomeDashboardData? dashboardData,
String? businessName,
String? photoUrl,
}) {
return ClientHomeState(
status: status ?? this.status,
@@ -56,6 +62,8 @@ class ClientHomeState extends Equatable {
isEditMode: isEditMode ?? this.isEditMode,
errorMessage: errorMessage ?? this.errorMessage,
dashboardData: dashboardData ?? this.dashboardData,
businessName: businessName ?? this.businessName,
photoUrl: photoUrl ?? this.photoUrl,
);
}
@@ -67,5 +75,7 @@ class ClientHomeState extends Equatable {
isEditMode,
errorMessage,
dashboardData,
businessName,
photoUrl,
];
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../widgets/shift_order_form_sheet.dart';
extension ClientHomeNavigator on IModularNavigator {
void pushSettings() {
@@ -13,3 +15,31 @@ extension ClientHomeNavigator on IModularNavigator {
pushNamed('/client/create-order/rapid');
}
}
/// 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: (context) {
return ShiftOrderFormSheet(
initialData: initialData,
onSubmit: (data) {
Navigator.pop(context);
onSubmit(data);
},
);
},
);
}
}

View File

@@ -3,43 +3,22 @@ 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:krow_data_connect/krow_data_connect.dart' as dc;
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import '../navigation/client_home_navigator.dart';
import '../widgets/actions_widget.dart';
import '../widgets/coverage_widget.dart';
import '../widgets/live_activity_widget.dart';
import '../widgets/reorder_widget.dart';
import '../widgets/shift_order_form_sheet.dart';
import '../widgets/spending_widget.dart';
import '../widgets/client_home_edit_banner.dart';
import '../widgets/client_home_header.dart';
import '../widgets/dashboard_widget_builder.dart';
/// The main Home page for client users.
///
/// This page displays a customizable dashboard with various widgets that can be
/// reordered and toggled on/off through edit mode.
class ClientHomePage extends StatelessWidget {
/// Creates a [ClientHomePage].
const ClientHomePage({super.key});
void _openOrderFormSheet(
BuildContext context,
Map<String, dynamic>? shiftData,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return ShiftOrderFormSheet(
initialData: shiftData,
onSubmit: (data) {
Navigator.pop(context);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final i18n = t.client_home;
@@ -51,59 +30,15 @@ class ClientHomePage extends StatelessWidget {
body: SafeArea(
child: Column(
children: [
_buildHeader(context, i18n),
_buildEditModeBanner(i18n),
ClientHomeHeader(i18n: i18n),
ClientHomeEditBanner(i18n: i18n),
Flexible(
child: BlocBuilder<ClientHomeBloc, ClientHomeState>(
builder: (context, state) {
if (state.isEditMode) {
return ReorderableListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
0,
UiConstants.space4,
100,
),
onReorder: (oldIndex, newIndex) {
BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((id) {
return Container(
key: ValueKey(id),
margin: const EdgeInsets.only(
bottom: UiConstants.space4,
),
child: _buildDraggableWidgetWrapper(
context,
id,
state,
),
);
}).toList(),
);
return _buildEditModeList(context, state);
}
return ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
0,
UiConstants.space4,
100,
),
children: state.widgetOrder.map((id) {
if (!(state.widgetVisibility[id] ?? true)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space4,
),
child: _buildWidgetContent(context, id, state),
);
}).toList(),
);
return _buildNormalModeList(state);
},
),
),
@@ -114,346 +49,53 @@ class ClientHomePage extends StatelessWidget {
);
}
Widget _buildHeader(BuildContext context, dynamic i18n) {
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
builder: (context, state) {
final session = dc.ClientSessionStore.instance.session;
final businessName =
session?.business?.businessName ?? 'Your Company';
final photoUrl = session?.userPhotoUrl;
final avatarLetter = businessName.trim().isNotEmpty
? businessName.trim()[0].toUpperCase()
: 'C';
return Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
UiConstants.space4,
UiConstants.space4,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: UiColors.primary.withValues(alpha: 0.2),
width: 2,
),
),
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,
),
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.dashboard.welcome_back,
style: UiTypography.footnote2r.textSecondary,
),
Text(businessName, style: UiTypography.body1b),
],
),
],
),
Row(
children: [
_HeaderIconButton(
icon: UiIcons.edit,
isActive: state.isEditMode,
onTap: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeEditModeToggled()),
),
const SizedBox(width: UiConstants.space2),
_HeaderIconButton(
icon: UiIcons.bell,
badgeText: '3',
onTap: () {},
),
const SizedBox(width: UiConstants.space2),
_HeaderIconButton(
icon: UiIcons.settings,
onTap: () => Modular.to.pushSettings(),
),
],
),
],
),
);
},
);
}
Widget _buildEditModeBanner(dynamic i18n) {
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
buildWhen: (prev, curr) => prev.isEditMode != curr.isEditMode,
builder: (context, state) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: state.isEditMode ? 76 : 0,
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space2,
),
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
borderRadius: UiConstants.radiusLg,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Icon(UiIcons.edit, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
i18n.dashboard.edit_mode_active,
style: UiTypography.footnote1b.copyWith(
color: UiColors.primary,
),
),
Text(
i18n.dashboard.drag_instruction,
style: UiTypography.footnote2r.textSecondary,
),
],
),
UiButton.secondary(
text: i18n.dashboard.reset,
onPressed: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeLayoutReset()),
size: UiButtonSize.small,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(double.infinity, 48),
),
),
],
),
);
},
);
}
Widget _buildDraggableWidgetWrapper(
BuildContext context,
String id,
ClientHomeState state,
) {
final isVisible = state.widgetVisibility[id] ?? true;
final title = _getWidgetTitle(id);
return Column(
spacing: UiConstants.space2,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: [
const Icon(
UiIcons.gripVertical,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space2),
Text(title, style: UiTypography.footnote1m),
],
),
),
const SizedBox(width: UiConstants.space2),
GestureDetector(
onTap: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeWidgetVisibilityToggled(id)),
child: Container(
padding: const EdgeInsets.all(UiConstants.space1),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Icon(
isVisible ? UiIcons.success : UiIcons.error,
size: 14,
color: isVisible ? UiColors.primary : UiColors.iconSecondary,
),
),
),
],
),
// Widget content
Opacity(
opacity: isVisible ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isVisible,
child: _buildWidgetContent(context, id, state),
),
),
],
);
}
Widget _buildWidgetContent(
BuildContext context,
String id,
ClientHomeState state,
) {
switch (id) {
case 'actions':
return ActionsWidget(
onRapidPressed: () => Modular.to.pushRapidOrder(),
onCreateOrderPressed: () => Modular.to.pushCreateOrder(),
);
case 'reorder':
return ReorderWidget(
onReorderPressed: (data) => _openOrderFormSheet(context, data),
);
case 'spending':
return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
);
case 'coverage':
return CoverageWidget(
totalNeeded: state.dashboardData.totalNeeded,
totalConfirmed: state.dashboardData.totalFilled,
coveragePercent: state.dashboardData.totalNeeded > 0
? ((state.dashboardData.totalFilled /
state.dashboardData.totalNeeded) *
100)
.toInt()
: 0,
);
case 'liveActivity':
return LiveActivityWidget(onViewAllPressed: () {});
default:
return const SizedBox.shrink();
}
}
String _getWidgetTitle(String id) {
final i18n = t.client_home.widgets;
switch (id) {
case 'actions':
return i18n.actions;
case 'reorder':
return i18n.reorder;
case 'coverage':
return i18n.coverage;
case 'spending':
return i18n.spending;
case 'liveActivity':
return i18n.live_activity;
default:
return '';
}
}
}
class _HeaderIconButton extends StatelessWidget {
final IconData icon;
final String? badgeText;
final bool isActive;
final VoidCallback onTap;
const _HeaderIconButton({
required this.icon,
this.badgeText,
this.isActive = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isActive ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusMd,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2,
),
],
),
child: Icon(
icon,
color: isActive ? UiColors.white : UiColors.iconSecondary,
size: 16,
),
),
if (badgeText != null)
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: UiColors.iconError,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Center(
child: Text(
badgeText!,
style: UiTypography.footnote2b.copyWith(
color: UiColors.white,
fontSize: 8,
),
),
),
),
),
],
/// Builds the widget list in edit mode with drag-and-drop support.
Widget _buildEditModeList(BuildContext context, ClientHomeState state) {
return ReorderableListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
0,
UiConstants.space4,
100,
),
onReorder: (oldIndex, newIndex) {
BlocProvider.of<ClientHomeBloc>(context).add(
ClientHomeWidgetReordered(oldIndex, newIndex),
);
},
children: state.widgetOrder.map((id) {
return Container(
key: ValueKey(id),
margin: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: true,
),
);
}).toList(),
);
}
/// Builds the widget list in normal mode with visibility filters.
Widget _buildNormalModeList(ClientHomeState state) {
return ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
0,
UiConstants.space4,
100,
),
children: state.widgetOrder.map((id) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: false,
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,79 @@
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';
/// A banner displayed when edit mode is active.
///
/// Shows instructions for reordering widgets and provides a reset button
/// to restore the default layout.
class ClientHomeEditBanner extends StatelessWidget {
/// The internationalization object for localized strings.
final dynamic i18n;
/// Creates a [ClientHomeEditBanner].
const ClientHomeEditBanner({
required this.i18n,
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
buildWhen: (prev, curr) => prev.isEditMode != curr.isEditMode,
builder: (context, state) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: state.isEditMode ? 76 : 0,
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space2,
),
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.1),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
borderRadius: UiConstants.radiusLg,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Icon(UiIcons.edit, size: 16, color: UiColors.primary),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
i18n.dashboard.edit_mode_active,
style: UiTypography.footnote1b.copyWith(
color: UiColors.primary,
),
),
Text(
i18n.dashboard.drag_instruction,
style: UiTypography.footnote2r.textSecondary,
),
],
),
UiButton.secondary(
text: i18n.dashboard.reset,
onPressed: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeLayoutReset()),
size: UiButtonSize.small,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(double.infinity, 48),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,114 @@
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 '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import '../navigation/client_home_navigator.dart';
import 'header_icon_button.dart';
/// The header section of the client home page.
///
/// Displays the user's business name, avatar, and action buttons
/// (edit mode, notifications, settings).
class ClientHomeHeader extends StatelessWidget {
/// The internationalization object for localized strings.
final dynamic i18n;
/// Creates a [ClientHomeHeader].
const ClientHomeHeader({
required this.i18n,
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<ClientHomeBloc, ClientHomeState>(
builder: (context, state) {
final businessName = state.businessName;
final photoUrl = state.photoUrl;
final avatarLetter = businessName.trim().isNotEmpty
? businessName.trim()[0].toUpperCase()
: 'C';
return Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
UiConstants.space4,
UiConstants.space4,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: UiColors.primary.withValues(alpha: 0.2),
width: 2,
),
),
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,
),
),
),
),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.dashboard.welcome_back,
style: UiTypography.footnote2r.textSecondary,
),
Text(businessName, style: UiTypography.body1b),
],
),
],
),
Row(
children: [
HeaderIconButton(
icon: UiIcons.edit,
isActive: state.isEditMode,
onTap: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeEditModeToggled()),
),
const SizedBox(width: UiConstants.space2),
HeaderIconButton(
icon: UiIcons.bell,
badgeText: '3',
onTap: () {},
),
const SizedBox(width: UiConstants.space2),
HeaderIconButton(
icon: UiIcons.settings,
onTap: () => Modular.to.pushSettings(),
),
],
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,119 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_home_state.dart';
import '../navigation/client_home_navigator.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';
/// 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.
class DashboardWidgetBuilder extends StatelessWidget {
/// The unique identifier for the widget to build.
final String id;
/// The current dashboard state.
final ClientHomeState state;
/// Whether the widget is in edit mode.
final bool isEditMode;
/// Creates a [DashboardWidgetBuilder].
const DashboardWidgetBuilder({
required this.id,
required this.state,
required this.isEditMode,
super.key,
});
@override
Widget build(BuildContext context) {
final i18n = t.client_home.widgets;
final widgetContent = _buildWidgetContent(context);
if (isEditMode) {
return DraggableWidgetWrapper(
id: id,
title: _getWidgetTitle(i18n),
isVisible: state.widgetVisibility[id] ?? true,
child: widgetContent,
);
}
// Hide widget if not visible in normal mode
if (!(state.widgetVisibility[id] ?? true)) {
return const SizedBox.shrink();
}
return widgetContent;
}
/// Builds the actual widget content based on the widget ID.
Widget _buildWidgetContent(BuildContext context) {
switch (id) {
case 'actions':
return ActionsWidget(
onRapidPressed: () => Modular.to.pushRapidOrder(),
onCreateOrderPressed: () => Modular.to.pushCreateOrder(),
);
case 'reorder':
return ReorderWidget(
onReorderPressed: (data) {
ClientHomeSheets.showOrderFormSheet(
context,
data,
onSubmit: (submittedData) {
// Handle form submission if needed
},
);
},
);
case 'spending':
return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
);
case 'coverage':
return CoverageWidget(
totalNeeded: state.dashboardData.totalNeeded,
totalConfirmed: state.dashboardData.totalFilled,
coveragePercent: state.dashboardData.totalNeeded > 0
? ((state.dashboardData.totalFilled /
state.dashboardData.totalNeeded) *
100)
.toInt()
: 0,
);
case 'liveActivity':
return LiveActivityWidget(onViewAllPressed: () {});
default:
return const SizedBox.shrink();
}
}
/// Returns the display title for the widget based on its ID.
String _getWidgetTitle(dynamic i18n) {
switch (id) {
case 'actions':
return i18n.actions;
case 'reorder':
return i18n.reorder;
case 'coverage':
return i18n.coverage;
case 'spending':
return i18n.spending;
case 'liveActivity':
return i18n.live_activity;
default:
return '';
}
}
}

View File

@@ -0,0 +1,95 @@
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';
/// A wrapper for dashboard widgets in edit mode.
///
/// Displays drag handles, visibility toggles, and wraps the actual widget
/// content with appropriate styling for the edit state.
class DraggableWidgetWrapper extends StatelessWidget {
/// The unique identifier for this widget.
final String id;
/// The display title for this widget.
final String title;
/// The actual widget content to wrap.
final Widget child;
/// Whether this widget is currently visible.
final bool isVisible;
/// Creates a [DraggableWidgetWrapper].
const DraggableWidgetWrapper({
required this.id,
required this.title,
required this.child,
required this.isVisible,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
spacing: UiConstants.space2,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: [
const Icon(
UiIcons.gripVertical,
size: 14,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space2),
Text(title, style: UiTypography.footnote1m),
],
),
),
const SizedBox(width: UiConstants.space2),
GestureDetector(
onTap: () => BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeWidgetVisibilityToggled(id)),
child: Container(
padding: const EdgeInsets.all(UiConstants.space1),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Icon(
isVisible ? UiIcons.success : UiIcons.error,
size: 14,
color: isVisible ? UiColors.primary : UiColors.iconSecondary,
),
),
),
],
),
// Widget content
Opacity(
opacity: isVisible ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !isVisible,
child: child,
),
),
],
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A circular icon button used in the header section.
///
/// Supports an optional badge for notification counts and an active state
/// for toggled actions.
class HeaderIconButton extends StatelessWidget {
/// The icon to display.
final IconData icon;
/// Optional badge text (e.g., notification count).
final String? badgeText;
/// Whether this button is in an active/selected state.
final bool isActive;
/// Callback invoked when the button is tapped.
final VoidCallback onTap;
/// Creates a [HeaderIconButton].
const HeaderIconButton({
required this.icon,
this.badgeText,
this.isActive = false,
required this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isActive ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusMd,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2,
),
],
),
child: Icon(
icon,
color: isActive ? UiColors.white : UiColors.iconSecondary,
size: 16,
),
),
if (badgeText != null)
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: UiColors.iconError,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Center(
child: Text(
badgeText!,
style: UiTypography.footnote2b.copyWith(
color: UiColors.white,
fontSize: 8,
),
),
),
),
),
],
),
);
}
}