feat: Implement rapid order creation via voice and text in mobile app

- Added benefits section with state management
- Refactored home page to include new sections for quick actions, today's shifts, and tomorrow's shifts
- Introduced full-width divider for better layout
- Created reusable section layout widget for consistent UI
- Implemented circular progress indicator for benefits
- Removed deprecated benefits widget and replaced with new structure
- Updated data connection configuration for validation environment
This commit is contained in:
Achintha Isuru
2026-03-03 20:28:12 -05:00
parent 017c0d4823
commit a7d66a1efe
18 changed files with 601 additions and 393 deletions

View File

@@ -20,6 +20,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
@override @override
Future<bool> getProfileCompletion() async { Future<bool> getProfileCompletion() async {
return true;
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();

View File

@@ -402,7 +402,7 @@ class UiTypography {
/// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) /// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
static final TextStyle body4m = _primaryBase.copyWith( static final TextStyle body4m = _primaryBase.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 12, fontSize: 10,
height: 1.5, height: 1.5,
letterSpacing: 0.05, letterSpacing: 0.05,
color: UiColors.textPrimary, color: UiColors.textPrimary,

View File

@@ -4,16 +4,15 @@ 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 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/full_width_divider.dart';
import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart'; import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart';
import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart'; import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart';
import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart'; import 'package:staff_home/src/presentation/widgets/home_page/quick_actions_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/recommended_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart'; import 'package:staff_home/src/presentation/widgets/home_page/todays_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/tomorrows_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart';
/// The home page for the staff worker application. /// The home page for the staff worker application.
/// ///
@@ -36,9 +35,6 @@ class WorkerHomePage extends StatelessWidget {
final t = Translations.of(context); final t = Translations.of(context);
final i18n = t.staff.home; final i18n = t.staff.home;
final bannersI18n = i18n.banners; final bannersI18n = i18n.banners;
final quickI18n = i18n.quick_actions;
final sectionsI18n = i18n.sections;
final emptyI18n = i18n.empty_states;
return BlocProvider<HomeCubit>.value( return BlocProvider<HomeCubit>.value(
value: Modular.get<HomeCubit>()..loadShifts(), value: Modular.get<HomeCubit>()..loadShifts(),
@@ -67,8 +63,7 @@ class WorkerHomePage extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
if (!state.isProfileComplete) { if (!state.isProfileComplete) {
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height - height: MediaQuery.of(context).size.height - 300,
300,
child: Column( child: Column(
children: [ children: [
PlaceholderBanner( PlaceholderBanner(
@@ -85,7 +80,8 @@ class WorkerHomePage extends StatelessWidget {
child: UiEmptyState( child: UiEmptyState(
icon: UiIcons.users, icon: UiIcons.users,
title: 'Complete Your Profile', title: 'Complete Your Profile',
description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.', description:
'Finish setting up your profile to unlock shifts, view earnings, and start earning today.',
), ),
), ),
], ],
@@ -96,147 +92,23 @@ class WorkerHomePage extends StatelessWidget {
return Column( return Column(
children: [ children: [
// Quick Actions // Quick Actions
Row( const QuickActionsSection(),
mainAxisAlignment: MainAxisAlignment.spaceBetween, const FullWidthDivider(),
children: [
Expanded(
child: QuickActionItem(
icon: UiIcons.search,
label: quickI18n.find_shifts,
onTap: () => Modular.to.toShifts(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.calendar,
label: quickI18n.availability,
onTap: () => Modular.to.toAvailability(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.dollar,
label: quickI18n.earnings,
onTap: () => Modular.to.toPayments(),
),
),
],
),
const SizedBox(height: UiConstants.space6),
// Today's Shifts // Today's Shifts
BlocBuilder<HomeCubit, HomeState>( const TodaysShiftsSection(),
builder: (context, state) { const FullWidthDivider(),
final shifts = state.todayShifts;
return Column(
children: [
SectionHeader(
title: sectionsI18n.todays_shift,
action: shifts.isNotEmpty
? sectionsI18n.scheduled_count(
count: shifts.length,
)
: null,
),
if (state.status == HomeStatus.loading)
const Center(
child: SizedBox(
height: UiConstants.space10,
width: UiConstants.space10,
child: CircularProgressIndicator(
color: UiColors.primary,
),
),
)
else if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_today,
actionLink: emptyI18n.find_shifts_cta,
onAction: () =>
Modular.to.toShifts(initialTab: 'find'),
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
// Tomorrow's Shifts // Tomorrow's Shifts
BlocBuilder<HomeCubit, HomeState>( const TomorrowsShiftsSection(),
builder: (context, state) { const FullWidthDivider(),
final shifts = state.tomorrowShifts;
return Column(
children: [
SectionHeader(title: sectionsI18n.tomorrow),
if (shifts.isEmpty)
EmptyStateWidget(
message: emptyI18n.no_shifts_tomorrow,
)
else
Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
],
);
},
),
const SizedBox(height: UiConstants.space3),
// Recommended Shifts // Recommended Shifts
SectionHeader(title: sectionsI18n.recommended_for_you), const RecommendedShiftsSection(),
BlocBuilder<HomeCubit, HomeState>( const FullWidthDivider(),
builder: (context, state) {
if (state.recommendedShifts.isEmpty) {
return EmptyStateWidget(
message: emptyI18n.no_recommended_shifts,
);
}
return SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.recommendedShifts.length,
clipBehavior: Clip.none,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(
right: UiConstants.space3,
),
child: RecommendedShiftCard(
shift: state.recommendedShifts[index],
),
),
),
);
},
),
const SizedBox(height: UiConstants.space6),
// Benefits // Benefits
BlocBuilder<HomeCubit, HomeState>( const BenefitsSection(),
buildWhen: (previous, current) =>
previous.benefits != current.benefits,
builder: (context, state) {
return BenefitsWidget(benefits: state.benefits);
},
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
], ],
); );

View File

@@ -0,0 +1,36 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart';
/// A widget that displays the benefits section.
///
/// Shows available benefits for the worker with state management
/// via BLoC to rebuild only when benefits data changes.
class BenefitsSection extends StatelessWidget {
/// Creates a [BenefitsSection].
const BenefitsSection({super.key});
@override
Widget build(BuildContext context) {
final i18n = t.staff.home.benefits;
return BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) =>
previous.benefits != current.benefits,
builder: (context, state) {
if (state.benefits.isEmpty) {
return const SizedBox.shrink();
}
return SectionLayout(
title: i18n.title,
child: BenefitsWidget(benefits: state.benefits),
);
},
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A divider that extends to full screen width, breaking out of parent padding.
///
/// This widget uses Transform.translate to shift the divider horizontally
/// to span the entire device width.
class FullWidthDivider extends StatelessWidget {
/// Creates a [FullWidthDivider].
const FullWidthDivider({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Column(
children: [
const SizedBox(height: UiConstants.space10),
Transform.translate(
offset: const Offset(UiConstants.space4, 0),
child: SizedBox(width: screenWidth, child: const Divider()),
),
const SizedBox(height: UiConstants.space10),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart';
/// A widget that displays quick action buttons for common tasks.
///
/// This section provides easy access to frequently used features like
/// finding shifts, setting availability, and viewing earnings.
class QuickActionsSection extends StatelessWidget {
/// Creates a [QuickActionsSection].
const QuickActionsSection({super.key});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final quickI18n = t.staff.home.quick_actions;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: QuickActionItem(
icon: UiIcons.search,
label: quickI18n.find_shifts,
onTap: () => Modular.to.toShifts(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.calendar,
label: quickI18n.availability,
onTap: () => Modular.to.toAvailability(),
),
),
Expanded(
child: QuickActionItem(
icon: UiIcons.dollar,
label: quickI18n.earnings,
onTap: () => Modular.to.toPayments(),
),
),
],
);
}
}

View File

@@ -23,47 +23,14 @@ class RecommendedShiftCard extends StatelessWidget {
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border, width: 0.5),
boxShadow: [
BoxShadow(
color: UiColors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row(
children: [
Text(
recI18n.act_now,
style: UiTypography.body3m.copyWith(
color: UiColors.textError,
),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2,
),
decoration: BoxDecoration(
color: UiColors.tagInProgress,
borderRadius: UiConstants.radiusFull,
),
child: Text(
recI18n.one_day,
style: UiTypography.body3m.textPrimary,
),
),
],
),
const SizedBox(height: UiConstants.space3),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -72,9 +39,7 @@ class RecommendedShiftCard extends StatelessWidget {
height: UiConstants.space10, height: UiConstants.space10,
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.tagInProgress, color: UiColors.tagInProgress,
borderRadius: BorderRadius.circular( borderRadius: UiConstants.radiusLg,
UiConstants.radiusBase,
),
), ),
child: const Icon( child: const Icon(
UiIcons.calendar, UiIcons.calendar,
@@ -89,6 +54,7 @@ class RecommendedShiftCard extends StatelessWidget {
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space1,
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
@@ -99,13 +65,13 @@ class RecommendedShiftCard extends StatelessWidget {
), ),
Text( Text(
'\$${shift.hourlyRate}/h', '\$${shift.hourlyRate}/h',
style: UiTypography.headline4m.textPrimary, style: UiTypography.headline4b,
), ),
], ],
), ),
const SizedBox(height: 2),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space1,
children: [ children: [
Text( Text(
shift.clientName, shift.clientName,
@@ -113,7 +79,7 @@ class RecommendedShiftCard extends StatelessWidget {
), ),
Text( Text(
'\$${shift.hourlyRate.toStringAsFixed(0)}/hr', '\$${shift.hourlyRate.toStringAsFixed(0)}/hr',
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r,
), ),
], ],
), ),

View File

@@ -0,0 +1,54 @@
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 'package:staff_home/src/presentation/blocs/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
/// A widget that displays recommended shifts section.
///
/// Shows a horizontal scrolling list of shifts recommended for the worker
/// based on their profile and preferences.
class RecommendedShiftsSection extends StatelessWidget {
/// Creates a [RecommendedShiftsSection].
const RecommendedShiftsSection({super.key});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final sectionsI18n = t.staff.home.sections;
final emptyI18n = t.staff.home.empty_states;
return SectionLayout(
title: sectionsI18n.recommended_for_you,
child: BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
if (state.recommendedShifts.isEmpty) {
return EmptyStateWidget(
message: emptyI18n.no_recommended_shifts,
);
}
return SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.recommendedShifts.length,
clipBehavior: Clip.none,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(
right: UiConstants.space3,
),
child: RecommendedShiftCard(
shift: state.recommendedShifts[index],
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,51 @@
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 {
/// The title of the section, displayed in the header.
final String? title;
/// Optional action text/widget to display on the right side of the header.
final String? action;
/// The main content of the section.
final Widget child;
/// Optional padding for the content area.
/// Defaults to no padding.
final EdgeInsetsGeometry? contentPadding;
/// Creates a [SectionLayout].
const SectionLayout({
this.title,
this.action,
required this.child,
this.contentPadding,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Padding(
padding: contentPadding ?? EdgeInsets.zero,
child: SectionHeader(
title: title!,
action: action,
),
),
const SizedBox(height: UiConstants.space2),
child,
],
);
}
}

View File

@@ -0,0 +1,68 @@
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 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
/// A widget that displays today's shifts section.
///
/// Shows a list of shifts scheduled for today, with loading state
/// and empty state handling.
class TodaysShiftsSection extends StatelessWidget {
/// Creates a [TodaysShiftsSection].
const TodaysShiftsSection({super.key});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final sectionsI18n = t.staff.home.sections;
final emptyI18n = t.staff.home.empty_states;
return BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.todayShifts;
return SectionLayout(
title: sectionsI18n.todays_shift,
action: shifts.isNotEmpty
? sectionsI18n.scheduled_count(
count: shifts.length,
)
: null,
child: state.status == HomeStatus.loading
? const Center(
child: SizedBox(
height: UiConstants.space10,
width: UiConstants.space10,
child: CircularProgressIndicator(
color: UiColors.primary,
),
),
)
: shifts.isEmpty
? EmptyStateWidget(
message: emptyI18n.no_shifts_today,
actionLink: emptyI18n.find_shifts_cta,
onAction: () =>
Modular.to.toShifts(initialTab: 'find'),
)
: Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
);
},
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
/// A widget that displays tomorrow's shifts section.
///
/// Shows a list of shifts scheduled for tomorrow with empty state handling.
class TomorrowsShiftsSection extends StatelessWidget {
/// Creates a [TomorrowsShiftsSection].
const TomorrowsShiftsSection({super.key});
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final sectionsI18n = t.staff.home.sections;
final emptyI18n = t.staff.home.empty_states;
return BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) {
final shifts = state.tomorrowShifts;
return SectionLayout(
title: sectionsI18n.tomorrow,
child: shifts.isEmpty
? EmptyStateWidget(
message: emptyI18n.no_shifts_tomorrow,
)
: Column(
children: shifts
.map(
(shift) => ShiftCard(
shift: shift,
compact: true,
),
)
.toList(),
),
);
},
);
}
}

View File

@@ -1,202 +0,0 @@
import 'dart:math' as math;
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Widget for displaying staff benefits, using design system tokens.
class BenefitsWidget extends StatelessWidget {
/// The list of benefits to display.
final List<Benefit> benefits;
/// Creates a [BenefitsWidget].
const BenefitsWidget({
required this.benefits,
super.key,
});
@override
Widget build(BuildContext context) {
final i18n = t.staff.home.benefits;
if (benefits.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
i18n.title,
style: UiTypography.body1b.textPrimary,
),
GestureDetector(
onTap: () => Modular.to.toBenefits(),
child: Row(
children: <Widget>[
Text(
i18n.view_all,
style: UiTypography.footnote2r.copyWith(
color: const Color(0xFF2563EB),
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
const Icon(
UiIcons.chevronRight,
size: 14,
color: Color(0xFF2563EB),
),
],
),
),
],
),
const SizedBox(height: UiConstants.space6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: benefits.map((Benefit benefit) {
return Expanded(
child: _BenefitItem(
label: benefit.title,
remaining: benefit.remainingHours,
total: benefit.entitlementHours,
used: benefit.usedHours,
color: const Color(0xFF2563EB),
),
);
}).toList(),
),
],
),
);
}
}
class _BenefitItem extends StatelessWidget {
final String label;
final double remaining;
final double total;
final double used;
final Color color;
const _BenefitItem({
required this.label,
required this.remaining,
required this.total,
required this.used,
required this.color,
});
@override
Widget build(BuildContext context) {
final double progress = total > 0 ? (remaining / total) : 0.0;
return Column(
children: <Widget>[
SizedBox(
width: 64,
height: 64,
child: CustomPaint(
painter: _CircularProgressPainter(
progress: progress,
color: color,
backgroundColor: const Color(0xFFE2E8F0),
strokeWidth: 5,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${remaining.toInt()}/${total.toInt()}',
style: UiTypography.body2b.textPrimary.copyWith(
fontSize: 12,
letterSpacing: -0.5,
),
),
Text(
'hours',
style: UiTypography.footnote2r.textTertiary.copyWith(
fontSize: 8,
),
),
],
),
),
),
),
const SizedBox(height: UiConstants.space3),
Text(
label,
style: UiTypography.footnote2r.textSecondary.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
);
}
}
class _CircularProgressPainter extends CustomPainter {
final double progress;
final Color color;
final Color backgroundColor;
final double strokeWidth;
_CircularProgressPainter({
required this.progress,
required this.color,
required this.backgroundColor,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(center, radius, backgroundPaint);
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,84 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'circular_progress_painter.dart';
/// A widget that displays a single benefit item with circular progress.
///
/// Shows remaining hours, total hours, and a progress indicator.
class BenefitItem extends StatelessWidget {
/// The label of the benefit (e.g., "Sick Leave", "PTO").
final String label;
/// The remaining hours available.
final double remaining;
/// The total hours entitled.
final double total;
/// The hours already used.
final double used;
/// The color for the progress indicator.
final Color color;
/// Creates a [BenefitItem].
const BenefitItem({
required this.label,
required this.remaining,
required this.total,
required this.used,
required this.color,
super.key,
});
@override
Widget build(BuildContext context) {
final double progress = total > 0 ? (remaining / total) : 0.0;
return Column(
children: <Widget>[
SizedBox(
width: 64,
height: 64,
child: CustomPaint(
painter: CircularProgressPainter(
progress: progress,
color: color,
backgroundColor: const Color(0xFFE2E8F0),
strokeWidth: 5,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'${remaining.toInt()}/${total.toInt()}',
style: UiTypography.body2b.textPrimary.copyWith(
fontSize: 12,
letterSpacing: -0.5,
),
),
Text(
'hours',
style: UiTypography.footnote2r.textTertiary.copyWith(
fontSize: 8,
),
),
],
),
),
),
),
const SizedBox(height: UiConstants.space3),
Text(
label,
style: UiTypography.footnote2r.textSecondary.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
/// A link widget that navigates to the full benefits page.
///
/// Displays "View all" text with a chevron icon.
class BenefitsViewAllLink extends StatelessWidget {
/// Creates a [BenefitsViewAllLink].
const BenefitsViewAllLink({super.key});
@override
Widget build(BuildContext context) {
final i18n = t.staff.home.benefits;
return GestureDetector(
onTap: () => Modular.to.toBenefits(),
child: Row(
children: <Widget>[
Text(
i18n.view_all,
style: UiTypography.footnote2r.copyWith(
color: const Color(0xFF2563EB),
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
const Icon(
UiIcons.chevronRight,
size: 14,
color: Color(0xFF2563EB),
),
],
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'benefit_item.dart';
import 'benefits_view_all_link.dart';
/// Widget for displaying staff benefits, using design system tokens.
///
/// Shows a list of benefits with circular progress indicators
/// and a link to view all benefits.
class BenefitsWidget extends StatelessWidget {
/// The list of benefits to display.
final List<Benefit> benefits;
/// Creates a [BenefitsWidget].
const BenefitsWidget({required this.benefits, super.key});
@override
Widget build(BuildContext context) {
if (benefits.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
children: <Widget>[
const Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
BenefitsViewAllLink(),
],
),
const SizedBox(height: UiConstants.space6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: benefits.map((Benefit benefit) {
return Expanded(
child: BenefitItem(
label: benefit.title,
remaining: benefit.remainingHours,
total: benefit.entitlementHours,
used: benefit.usedHours,
color: const Color(0xFF2563EB),
),
);
}).toList(),
),
],
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// A custom painter for drawing circular progress indicators.
///
/// Draws a background circle and a progress arc on top of it.
class CircularProgressPainter extends CustomPainter {
/// The progress value (0.0 to 1.0).
final double progress;
/// The color of the progress arc.
final Color color;
/// The color of the background circle.
final Color backgroundColor;
/// The width of the stroke.
final double strokeWidth;
/// Creates a [CircularProgressPainter].
CircularProgressPainter({
required this.progress,
required this.color,
required this.backgroundColor,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
// Draw background circle
final backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(center, radius, backgroundPaint);
// Draw progress arc
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -1,5 +1,5 @@
specVersion: "v1" specVersion: "v1"
serviceId: "krow-workforce-db" serviceId: "krow-workforce-db-validation"
location: "us-central1" location: "us-central1"
schema: schema:
source: "./schema" source: "./schema"
@@ -7,7 +7,7 @@ schema:
postgresql: postgresql:
database: "krow_db" database: "krow_db"
cloudSql: cloudSql:
instanceId: "krow-sql" instanceId: "krow-sql-validation"
# schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly.
# schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect.
connectorDirs: ["./connector"] connectorDirs: ["./connector"]

View File

@@ -7,7 +7,7 @@
# make dataconnect-clean DC_ENV=validation # make dataconnect-clean DC_ENV=validation
# make dataconnect-generate-sdk DC_ENV=dev # make dataconnect-generate-sdk DC_ENV=dev
# #
DC_ENV ?= dev DC_ENV ?= validation
DC_LOCATION ?= us-central1 DC_LOCATION ?= us-central1
DC_CONNECTOR_ID ?= example DC_CONNECTOR_ID ?= example