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:
@@ -5,6 +5,7 @@ import 'package:krow_data_connect/krow_data_connect.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_user_session_data_usecase.dart';
|
||||
import 'src/presentation/blocs/client_home_bloc.dart';
|
||||
import 'src/presentation/pages/client_home_page.dart';
|
||||
|
||||
@@ -28,11 +29,13 @@ class ClientHomeModule extends Module {
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(GetDashboardDataUseCase.new);
|
||||
i.addLazySingleton(GetUserSessionDataUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.add<ClientHomeBloc>(
|
||||
() => ClientHomeBloc(
|
||||
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
|
||||
getUserSessionDataUseCase: i.get<GetUserSessionDataUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user