feat: Refactor client home widgets to use SectionLayout and add titles

- Updated ActionsWidget, CoverageWidget, SpendingWidget, ReorderWidget, and LiveActivityWidget to utilize SectionLayout for consistent layout structure.
- Introduced SectionHeader for displaying titles and optional actions in sections.
- Added ClientHomeBody, ClientHomeEditModeBody, ClientHomeNormalModeBody, and ClientHomeErrorState for improved state management and UI separation.
- Enhanced dashboard widget builder to support edit mode and error handling.
This commit is contained in:
Achintha Isuru
2026-03-03 22:00:42 -05:00
parent 2d20254ce3
commit 6f2a195724
13 changed files with 721 additions and 529 deletions

View File

@@ -1,15 +1,12 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_home_bloc.dart'; import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart'; import '../widgets/client_home_body.dart';
import '../blocs/client_home_state.dart';
import '../widgets/client_home_edit_banner.dart'; import '../widgets/client_home_edit_banner.dart';
import '../widgets/client_home_header.dart'; import '../widgets/client_home_header.dart';
import '../widgets/dashboard_widget_builder.dart';
/// The main Home page for client users. /// The main Home page for client users.
/// ///
@@ -21,133 +18,23 @@ class ClientHomePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home;
return BlocProvider<ClientHomeBloc>( return BlocProvider<ClientHomeBloc>(
create: (BuildContext context) => Modular.get<ClientHomeBloc>(), create: (BuildContext context) => Modular.get<ClientHomeBloc>(),
child: Scaffold( child: Scaffold(
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
ClientHomeHeader(i18n: i18n), ClientHomeHeader(
ClientHomeEditBanner(i18n: i18n), i18n: t.client_home,
Flexible(
child: BlocConsumer<ClientHomeBloc, ClientHomeState>(
listener: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error) {
return _buildErrorState(context, state);
}
if (state.isEditMode) {
return _buildEditModeList(context, state);
}
return _buildNormalModeList(state);
},
),
), ),
ClientHomeEditBanner(
i18n: t.client_home,
),
const ClientHomeBody(),
], ],
), ),
), ),
), ),
); );
} }
/// 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: (int oldIndex, int newIndex) {
BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((String id) {
return Container(
key: ValueKey<String>(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) {
final List<String> visibleWidgets = state.widgetOrder.where((String id) {
if (id == 'reorder' && state.reorderItems.isEmpty) {
return false;
}
return state.widgetVisibility[id] ?? true;
}).toList();
return ListView.separated(
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: UiColors.border, height: 0.1);
},
itemCount: visibleWidgets.length,
itemBuilder: (BuildContext context, int index) {
final String id = visibleWidgets[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (index != 0) const SizedBox(height: UiConstants.space8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: false,
),
),
const SizedBox(height: UiConstants.space8),
],
);
},
);
}
Widget _buildErrorState(BuildContext context, ClientHomeState state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.error,
size: 48,
color: UiColors.error,
),
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () =>
BlocProvider.of<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
} }

View File

@@ -4,10 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'section_layout.dart';
/// A widget that displays quick actions for the client. /// A widget that displays quick actions for the client.
class ActionsWidget extends StatelessWidget { class ActionsWidget extends StatelessWidget {
/// Creates an [ActionsWidget]. /// Creates an [ActionsWidget].
const ActionsWidget({super.key, this.subtitle}); const ActionsWidget({
super.key,
this.title,
this.subtitle,
});
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -17,38 +26,41 @@ class ActionsWidget extends StatelessWidget {
// Check if client_home exists in t // Check if client_home exists in t
final TranslationsClientHomeActionsEn i18n = t.client_home.actions; final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
return Row( return SectionLayout(
spacing: UiConstants.space4, title: title,
children: <Widget>[ child: Row(
Expanded( spacing: UiConstants.space4,
child: _ActionCard( children: <Widget>[
title: i18n.rapid, Expanded(
subtitle: i18n.rapid_subtitle, child: _ActionCard(
icon: UiIcons.zap, title: i18n.rapid,
color: UiColors.tagError.withValues(alpha: 0.5), subtitle: i18n.rapid_subtitle,
borderColor: UiColors.borderError.withValues(alpha: 0.3), icon: UiIcons.zap,
iconBgColor: UiColors.white, color: UiColors.tagError.withValues(alpha: 0.5),
iconColor: UiColors.textError, borderColor: UiColors.borderError.withValues(alpha: 0.3),
textColor: UiColors.textError, iconBgColor: UiColors.white,
subtitleColor: UiColors.textError.withValues(alpha: 0.8), iconColor: UiColors.textError,
onTap: () => Modular.to.toCreateOrderRapid(), textColor: UiColors.textError,
subtitleColor: UiColors.textError.withValues(alpha: 0.8),
onTap: () => Modular.to.toCreateOrderRapid(),
),
), ),
), Expanded(
Expanded( child: _ActionCard(
child: _ActionCard( title: i18n.create_order,
title: i18n.create_order, subtitle: i18n.create_order_subtitle,
subtitle: i18n.create_order_subtitle, icon: UiIcons.add,
icon: UiIcons.add, color: UiColors.white,
color: UiColors.white, borderColor: UiColors.border,
borderColor: UiColors.border, iconBgColor: UiColors.primaryForeground,
iconBgColor: UiColors.primaryForeground, iconColor: UiColors.primary,
iconColor: UiColors.primary, textColor: UiColors.textPrimary,
textColor: UiColors.textPrimary, subtitleColor: UiColors.textSecondary,
subtitleColor: UiColors.textSecondary, onTap: () => Modular.to.toCreateOrder(),
onTap: () => Modular.to.toCreateOrder(), ),
), ),
), ],
], ),
); );
} }
} }

View File

@@ -0,0 +1,45 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../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';
/// Main body widget for the client home page.
///
/// Manages the state transitions between error, edit mode, and normal mode views.
class ClientHomeBody extends StatelessWidget {
/// Creates a [ClientHomeBody].
const ClientHomeBody({super.key});
@override
Widget build(BuildContext context) {
return Flexible(
child: BlocConsumer<ClientHomeBloc, ClientHomeState>(
listener: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error) {
return ClientHomeErrorState(state: state);
}
if (state.isEditMode) {
return ClientHomeEditModeBody(state: state);
}
return ClientHomeNormalModeBody(state: state);
},
),
);
}
}

View File

@@ -0,0 +1,49 @@
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';
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
///
/// Allows users to reorder and rearrange dashboard widgets.
class ClientHomeEditModeBody extends StatelessWidget {
/// The current home state.
final ClientHomeState state;
/// Creates a [ClientHomeEditModeBody].
const ClientHomeEditModeBody({
required this.state,
super.key,
});
@override
Widget build(BuildContext context) {
return ReorderableListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space4,
0,
UiConstants.space4,
100,
),
onReorder: (int oldIndex, int newIndex) {
BlocProvider.of<ClientHomeBloc>(context)
.add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((String id) {
return Container(
key: ValueKey<String>(id),
margin: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: true,
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
/// Widget that displays an error state for the client home page.
///
/// Shows an error message with a retry button when data fails to load.
class ClientHomeErrorState extends StatelessWidget {
/// The current home state containing error information.
final ClientHomeState state;
/// Creates a [ClientHomeErrorState].
const ClientHomeErrorState({
required this.state,
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.error,
size: 48,
color: UiColors.error,
),
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () =>
BlocProvider.of<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../blocs/client_home_state.dart';
import 'dashboard_widget_builder.dart';
/// Widget that displays the home dashboard in normal mode.
///
/// Shows visible dashboard widgets in a vertical scrollable list with dividers.
class ClientHomeNormalModeBody extends StatelessWidget {
/// The current home state.
final ClientHomeState state;
/// Creates a [ClientHomeNormalModeBody].
const ClientHomeNormalModeBody({
required this.state,
super.key,
});
@override
Widget build(BuildContext context) {
final List<String> visibleWidgets = state.widgetOrder.where((String id) {
if (id == 'reorder' && state.reorderItems.isEmpty) {
return false;
}
return state.widgetVisibility[id] ?? true;
}).toList();
return ListView.separated(
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: UiColors.border, height: 0.1);
},
itemCount: visibleWidgets.length,
itemBuilder: (BuildContext context, int index) {
final String id = visibleWidgets[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (index != 0) const SizedBox(height: UiConstants.space8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: false,
),
),
const SizedBox(height: UiConstants.space8),
],
);
},
);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'section_layout.dart';
/// A widget that displays the daily coverage metrics. /// A widget that displays the daily coverage metrics.
class CoverageWidget extends StatelessWidget { class CoverageWidget extends StatelessWidget {
/// Creates a [CoverageWidget]. /// Creates a [CoverageWidget].
@@ -10,6 +12,7 @@ class CoverageWidget extends StatelessWidget {
this.totalNeeded = 0, this.totalNeeded = 0,
this.totalConfirmed = 0, this.totalConfirmed = 0,
this.coveragePercent = 0, this.coveragePercent = 0,
this.title,
this.subtitle, this.subtitle,
}); });
@@ -22,84 +25,42 @@ class CoverageWidget extends StatelessWidget {
/// The percentage of coverage (0-100). /// The percentage of coverage (0-100).
final int coveragePercent; final int coveragePercent;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Color backgroundColor; return SectionLayout(
Color textColor; title: title,
action: totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0
if (coveragePercent == 100) { ? t.client_home.dashboard.percent_covered(percent: coveragePercent)
backgroundColor = UiColors.tagActive; : null,
textColor = UiColors.textSuccess; child: Row(
} else if (coveragePercent >= 40) { children: <Widget>[
backgroundColor = UiColors.tagPending; Expanded(
textColor = UiColors.textWarning; child: _MetricCard(
} else { icon: UiIcons.target,
backgroundColor = UiColors.tagError; iconColor: UiColors.primary,
textColor = UiColors.textError; label: t.client_home.dashboard.metric_needed,
} value: '$totalNeeded',
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_home.dashboard.todays_coverage,
style: UiTypography.footnote1b.copyWith(
color: UiColors.textPrimary,
letterSpacing: 0.5,
),
), ),
if (totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0) ),
Container( const SizedBox(width: UiConstants.space2),
padding: const EdgeInsets.symmetric( if (totalConfirmed != 0)
horizontal: UiConstants.space2,
vertical:
2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: UiConstants.radiusLg,
),
child: Text(
t.client_home.dashboard.percent_covered(
percent: coveragePercent,
),
style: UiTypography.footnote2b.copyWith(color: textColor),
),
),
],
),
if (subtitle != null) ...<Widget>[
Text(subtitle!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space6),
Row(
children: <Widget>[
Expanded( Expanded(
child: _MetricCard( child: _MetricCard(
icon: UiIcons.target, icon: UiIcons.success,
iconColor: UiColors.primary, iconColor: UiColors.iconSuccess,
label: t.client_home.dashboard.metric_needed, label: t.client_home.dashboard.metric_filled,
value: '$totalNeeded', value: '$totalConfirmed',
valueColor: UiColors.textSuccess,
), ),
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
if (totalConfirmed != 0) if (totalConfirmed != 0)
Expanded(
child: _MetricCard(
icon: UiIcons.success,
iconColor: UiColors.iconSuccess,
label: t.client_home.dashboard.metric_filled,
value: '$totalConfirmed',
valueColor: UiColors.textSuccess,
),
),
const SizedBox(width: UiConstants.space2),
Expanded( Expanded(
child: _MetricCard( child: _MetricCard(
icon: UiIcons.error, icon: UiIcons.error,
@@ -109,9 +70,8 @@ class CoverageWidget extends StatelessWidget {
valueColor: UiColors.textError, valueColor: UiColors.textError,
), ),
), ),
], ],
), ),
],
); );
} }
} }

View File

@@ -35,7 +35,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets; final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets;
final Widget widgetContent = _buildWidgetContent(context); final Widget widgetContent = _buildWidgetContent(context, i18n);
if (isEditMode) { if (isEditMode) {
return DraggableWidgetWrapper( return DraggableWidgetWrapper(
@@ -55,21 +55,27 @@ class DashboardWidgetBuilder extends StatelessWidget {
} }
/// Builds the actual widget content based on the widget ID. /// Builds the actual widget content based on the widget ID.
Widget _buildWidgetContent(BuildContext context) { Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) {
final String title = _getWidgetTitle(i18n);
// Only show subtitle in normal mode // Only show subtitle in normal mode
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null; final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
switch (id) { switch (id) {
case 'actions': case 'actions':
return ActionsWidget(subtitle: subtitle); return ActionsWidget(title: title, subtitle: subtitle);
case 'reorder': case 'reorder':
return ReorderWidget(orders: state.reorderItems, subtitle: subtitle); return ReorderWidget(
orders: state.reorderItems,
title: title,
subtitle: subtitle,
);
case 'spending': case 'spending':
return SpendingWidget( return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending, weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending, next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts, weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled, next7DaysScheduled: state.dashboardData.next7DaysScheduled,
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
case 'coverage': case 'coverage':
@@ -82,11 +88,13 @@ class DashboardWidgetBuilder extends StatelessWidget {
100) 100)
.toInt() .toInt()
: 0, : 0,
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
case 'liveActivity': case 'liveActivity':
return LiveActivityWidget( return LiveActivityWidget(
onViewAllPressed: () => Modular.to.toClientCoverage(), onViewAllPressed: () => Modular.to.toClientCoverage(),
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
default: default:

View File

@@ -1,9 +1,10 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; 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_data_connect/krow_data_connect.dart' as dc;
import 'coverage_dashboard.dart'; import 'coverage_dashboard.dart';
import 'section_layout.dart';
/// A widget that displays live activity information. /// A widget that displays live activity information.
class LiveActivityWidget extends StatefulWidget { class LiveActivityWidget extends StatefulWidget {
@@ -12,11 +13,15 @@ class LiveActivityWidget extends StatefulWidget {
const LiveActivityWidget({ const LiveActivityWidget({
super.key, super.key,
required this.onViewAllPressed, required this.onViewAllPressed,
this.title,
this.subtitle this.subtitle
}); });
/// Callback when "View all" is pressed. /// Callback when "View all" is pressed.
final VoidCallback onViewAllPressed; final VoidCallback onViewAllPressed;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -106,73 +111,46 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home; final TranslationsClientHomeEn i18n = t.client_home;
return Column( return SectionLayout(
crossAxisAlignment: CrossAxisAlignment.start, title: widget.title,
children: <Widget>[ action: i18n.dashboard.view_all,
Row( onAction: widget.onViewAllPressed,
mainAxisAlignment: MainAxisAlignment.spaceBetween, child: FutureBuilder<_LiveActivityData>(
children: <Widget>[ future: _liveActivityFuture,
Text( builder: (BuildContext context,
i18n.widgets.live_activity.toUpperCase(), AsyncSnapshot<_LiveActivityData> snapshot) {
style: UiTypography.footnote1b.textSecondary.copyWith( final _LiveActivityData data =
letterSpacing: 0.5, snapshot.data ?? _LiveActivityData.empty();
), final List<Map<String, Object>> shifts =
), <Map<String, Object>>[
GestureDetector( <String, Object>{
onTap: widget.onViewAllPressed, 'workersNeeded': data.totalNeeded,
child: Text( 'filled': data.totalAssigned,
i18n.dashboard.view_all, 'hourlyRate': 1.0,
style: UiTypography.footnote1m.copyWith( 'hours': data.totalCost,
color: UiColors.primary, 'status': 'OPEN',
), 'date': DateTime.now().toIso8601String().split('T')[0],
), },
), ];
], final List<Map<String, Object?>> applications =
), <Map<String, Object?>>[];
if (widget.subtitle != null) ...<Widget>[ for (int i = 0; i < data.checkedInCount; i += 1) {
Text( applications.add(
widget.subtitle!, <String, Object?>{
style: UiTypography.body2r.textSecondary, 'status': 'CONFIRMED',
), 'checkInTime': '09:00',
],
const SizedBox(height: UiConstants.space6),
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,
); );
}, }
), for (int i = 0; i < data.lateCount; i += 1) {
], applications.add(<String, Object?>{'status': 'LATE'});
}
return CoverageDashboard(
shifts: shifts,
applications: applications,
);
},
),
); );
} }
} }

View File

@@ -5,14 +5,24 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'section_layout.dart';
/// A widget that allows clients to reorder recent shifts. /// A widget that allows clients to reorder recent shifts.
class ReorderWidget extends StatelessWidget { class ReorderWidget extends StatelessWidget {
/// Creates a [ReorderWidget]. /// Creates a [ReorderWidget].
const ReorderWidget({super.key, required this.orders, this.subtitle}); const ReorderWidget({
super.key,
required this.orders,
this.title,
this.subtitle,
});
/// Recent completed orders for reorder. /// Recent completed orders for reorder.
final List<ReorderItem> orders; final List<ReorderItem> orders;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -26,147 +36,134 @@ class ReorderWidget extends StatelessWidget {
final List<ReorderItem> recentOrders = orders; final List<ReorderItem> recentOrders = orders;
return Column( return SectionLayout(
crossAxisAlignment: CrossAxisAlignment.start, title: title,
children: <Widget>[ child: SizedBox(
Text( height: 164,
i18n.title, child: ListView.separated(
style: UiTypography.footnote1b.textSecondary.copyWith( scrollDirection: Axis.horizontal,
letterSpacing: 0.5, itemCount: recentOrders.length,
), separatorBuilder: (BuildContext context, int index) =>
), const SizedBox(width: UiConstants.space3),
if (subtitle != null) ...<Widget>[ itemBuilder: (BuildContext context, int index) {
const SizedBox(height: UiConstants.space1), final ReorderItem order = recentOrders[index];
Text(subtitle!, style: UiTypography.body2r.textSecondary), final double totalCost = order.totalCost;
],
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 164,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentOrders.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;
return Container( return Container(
width: 260, width: 260,
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.6), border: Border.all(color: UiColors.border, width: 0.6),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: Row( child: Row(
children: <Widget>[
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: UiColors.primary.withValues(
alpha: 0.1,
),
borderRadius: UiConstants.radiusLg,
),
child: const Icon(
UiIcons.building,
size: 16,
color: UiColors.primary,
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
order.title,
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[ children: <Widget>[
Text( Container(
'\$${totalCost.toStringAsFixed(0)}', width: 36,
style: UiTypography.body1b, height: 36,
decoration: BoxDecoration(
color: UiColors.primary.withValues(
alpha: 0.1,
),
borderRadius: UiConstants.radiusLg,
),
child: const Icon(
UiIcons.building,
size: 16,
color: UiColors.primary,
),
), ),
Text( const SizedBox(width: UiConstants.space2),
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h', Expanded(
style: UiTypography.footnote2r.textSecondary, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
order.title,
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
], ],
), ),
], ),
), Column(
const SizedBox(height: UiConstants.space3), crossAxisAlignment: CrossAxisAlignment.end,
Row( children: <Widget>[
children: <Widget>[ Text(
_Badge( '\$${totalCost.toStringAsFixed(0)}',
icon: UiIcons.success, style: UiTypography.body1b,
text: order.type, ),
color: UiColors.primary, Text(
bg: UiColors.buttonSecondaryStill, '${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
textColor: UiColors.primary, style: UiTypography.footnote2r.textSecondary,
), ),
const SizedBox(width: UiConstants.space2), ],
_Badge( ),
icon: UiIcons.building, ],
text: '${order.workers}', ),
color: UiColors.textSecondary, const SizedBox(height: UiConstants.space3),
bg: UiColors.buttonSecondaryStill, Row(
textColor: UiColors.textSecondary, children: <Widget>[
), _Badge(
], icon: UiIcons.success,
), text: order.type,
const Spacer(), color: UiColors.primary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order.workers}',
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.textSecondary,
),
],
),
const Spacer(),
UiButton.secondary( UiButton.secondary(
size: UiButtonSize.small, size: UiButtonSize.small,
text: i18n.reorder_button, text: i18n.reorder_button,
leadingIcon: UiIcons.zap, leadingIcon: UiIcons.zap,
iconSize: 12, iconSize: 12,
fullWidth: true, fullWidth: true,
onPressed: () => onPressed: () =>
_handleReorderPressed(context, <String, dynamic>{ _handleReorderPressed(context, <String, dynamic>{
'orderId': order.orderId, 'orderId': order.orderId,
'title': order.title, 'title': order.title,
'location': order.location, 'location': order.location,
'hourlyRate': order.hourlyRate, 'hourlyRate': order.hourlyRate,
'hours': order.hours, 'hours': order.hours,
'workers': order.workers, 'workers': order.workers,
'type': order.type, 'type': order.type,
'totalCost': order.totalCost, 'totalCost': order.totalCost,
}), }),
), ),
], ],
), ),
); );
}, },
),
), ),
], ),
); );
} }

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
/// Section header widget for home page sections, using design system tokens.
class SectionHeader extends StatelessWidget {
/// Section title
final String title;
/// Optional subtitle
final String? subtitle;
/// Optional action label
final String? action;
/// Optional action callback
final VoidCallback? onAction;
/// Creates a [SectionHeader].
const SectionHeader({
super.key,
required this.title,
this.subtitle,
this.action,
this.onAction,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: action != null
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.body1b),
if (onAction != null)
GestureDetector(
onTap: onAction,
child: Row(
children: <Widget>[
Text(
action ?? '',
style: UiTypography.body3r.textSecondary,
),
const Icon(
UiIcons.chevronRight,
size: UiConstants.space4,
color: UiColors.iconSecondary,
),
],
),
)
else
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.primary.withValues(alpha: 0.08),
borderRadius: UiConstants.radiusMd,
border: Border.all(
color: UiColors.primary,
width: 0.5,
),
),
child: Text(
action!,
style: UiTypography.body3r.primary,
),
),
],
)
: Text(title, style: UiTypography.body1b),
),
],
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Text(
subtitle!,
style: UiTypography.body2r.textSecondary,
),
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'section_header.dart';
/// A common layout widget for home page sections.
///
/// Provides consistent structure with optional header and content area.
/// Use this to ensure all sections follow the same layout pattern.
class SectionLayout extends StatelessWidget {
/// Creates a [SectionLayout].
const SectionLayout({
this.title,
this.subtitle,
this.action,
this.onAction,
required this.child,
this.contentPadding,
super.key,
});
/// The title of the section, displayed in the header.
final String? title;
/// Optional subtitle for the section.
final String? subtitle;
/// Optional action text/widget to display on the right side of the header.
final String? action;
/// Optional callback when action is tapped.
final VoidCallback? onAction;
/// The main content of the section.
final Widget child;
/// Optional padding for the content area.
/// Defaults to no padding.
final EdgeInsetsGeometry? contentPadding;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (title != null)
Padding(
padding: contentPadding ?? EdgeInsets.zero,
child: SectionHeader(
title: title!,
subtitle: subtitle,
action: action,
onAction: onAction,
),
),
const SizedBox(height: UiConstants.space2),
child,
],
);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'section_layout.dart';
/// A widget that displays spending insights for the client. /// A widget that displays spending insights for the client.
class SpendingWidget extends StatelessWidget { class SpendingWidget extends StatelessWidget {
@@ -12,6 +14,7 @@ class SpendingWidget extends StatelessWidget {
required this.next7DaysSpending, required this.next7DaysSpending,
required this.weeklyShifts, required this.weeklyShifts,
required this.next7DaysScheduled, required this.next7DaysScheduled,
this.title,
this.subtitle, this.subtitle,
}); });
/// The spending this week. /// The spending this week.
@@ -26,117 +29,103 @@ class SpendingWidget extends StatelessWidget {
/// The number of scheduled shifts for next 7 days. /// The number of scheduled shifts for next 7 days.
final int next7DaysScheduled; final int next7DaysScheduled;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home; return SectionLayout(
title: title,
return Column( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(UiConstants.space3),
children: <Widget>[ decoration: BoxDecoration(
Text( gradient: LinearGradient(
i18n.widgets.spending.toUpperCase(), colors: <Color>[
style: UiTypography.footnote1b.textSecondary.copyWith( UiColors.primary,
letterSpacing: 0.5, UiColors.primary.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
), ),
), borderRadius: UiConstants.radiusLg,
if (subtitle != null) ...<Widget>[ boxShadow: <BoxShadow>[
Text( BoxShadow(
subtitle!, color: UiColors.primary.withValues(alpha: 0.3),
style: UiTypography.body2r.textSecondary, blurRadius: 4,
), offset: const Offset(0, 4),
],
const SizedBox(height: UiConstants.space6),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.primary.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
), ),
borderRadius: UiConstants.radiusLg, ],
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_home.dashboard.spending.this_week,
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9,
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${weeklySpending.toStringAsFixed(0)}',
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,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
t.client_home.dashboard.spending.next_7_days,
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9,
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${next7DaysSpending.toStringAsFixed(0)}',
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,
),
),
],
),
),
],
),
],
),
), ),
], child: Column(
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_home.dashboard.spending.this_week,
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9,
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${weeklySpending.toStringAsFixed(0)}',
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,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
t.client_home.dashboard.spending.next_7_days,
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9,
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${next7DaysSpending.toStringAsFixed(0)}',
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,
),
),
],
),
),
],
),
],
),
),
); );
} }
} }