diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart new file mode 100644 index 00000000..6bd626bd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/section_header.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A section header with a colored dot indicator and title text. +class SectionHeader extends StatelessWidget { + /// Creates a [SectionHeader]. + const SectionHeader({ + super.key, + required this.title, + required this.dotColor, + }); + + /// The header title text. + final String title; + + /// The color of the leading dot indicator. + final Color dotColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: UiConstants.space2), + Text( + title, + style: dotColor == UiColors.textSecondary + ? UiTypography.body2b.textSecondary + : UiTypography.body2b.copyWith(color: dotColor), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart new file mode 100644 index 00000000..eb41d04a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/shift_section_list.dart @@ -0,0 +1,162 @@ +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' show ReadContext; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; +import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'section_header.dart'; + +/// Scrollable list displaying pending, cancelled, and confirmed shift sections. +/// +/// Renders each section with a [SectionHeader] and a list of [ShiftCard] +/// widgets. Shows an [EmptyStateView] when all sections are empty. +class ShiftSectionList extends StatelessWidget { + /// Creates a [ShiftSectionList]. + const ShiftSectionList({ + super.key, + required this.assignedShifts, + required this.pendingAssignments, + required this.cancelledShifts, + this.submittedShiftIds = const {}, + this.submittingShiftId, + }); + + /// Confirmed/assigned shifts visible for the selected day. + final List assignedShifts; + + /// Pending assignments awaiting acceptance. + final List pendingAssignments; + + /// Cancelled shifts visible for the selected week. + final List cancelledShifts; + + /// Set of shift IDs that have been successfully submitted for approval. + final Set submittedShiftIds; + + /// The shift ID currently being submitted (null when idle). + final String? submittingShiftId; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + children: [ + const SizedBox(height: UiConstants.space5), + + // Pending assignments section + if (pendingAssignments.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.awaiting, + dotColor: UiColors.textWarning, + ), + ...pendingAssignments.map( + (PendingAssignment assignment) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromPending(assignment), + onTap: () => + Modular.to.toShiftDetailsById(assignment.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Cancelled shifts section + if (cancelledShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.cancelled, + dotColor: UiColors.textSecondary, + ), + ...cancelledShifts.map( + (CancelledShift cs) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromCancelled(cs), + onTap: () => + Modular.to.toShiftDetailsById(cs.shiftId), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + ], + + // Confirmed shifts section + if (assignedShifts.isNotEmpty) ...[ + SectionHeader( + title: + context.t.staff_shifts.my_shifts_tab.sections.confirmed, + dotColor: UiColors.textSecondary, + ), + ...assignedShifts.map( + (AssignedShift shift) { + final bool isCompleted = + shift.status == AssignmentStatus.completed; + final bool isSubmitted = + submittedShiftIds.contains(shift.shiftId); + final bool isSubmitting = + submittingShiftId == shift.shiftId; + + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ShiftCard( + data: ShiftCardData.fromAssigned(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), + showApprovalAction: isCompleted, + isSubmitted: isSubmitted, + isSubmitting: isSubmitting, + onSubmitForApproval: () { + ReadContext(context).read().add( + SubmitForApprovalEvent( + shiftId: shift.shiftId, + ), + ); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, + ), + ], + + // Empty state + if (assignedShifts.isEmpty && + pendingAssignments.isEmpty && + cancelledShifts.isEmpty) + EmptyStateView( + icon: UiIcons.calendar, + title: context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: + context.t.staff_shifts.my_shifts_tab.empty.subtitle, + ), + + const SizedBox(height: UiConstants.space32), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart new file mode 100644 index 00000000..7bf42b7a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts/week_calendar_selector.dart @@ -0,0 +1,159 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A week-view calendar selector showing 7 days with navigation arrows. +/// +/// Displays a month/year header with chevron arrows for week navigation and +/// a row of day cells. Days with assigned shifts show a dot indicator. +class WeekCalendarSelector extends StatelessWidget { + /// Creates a [WeekCalendarSelector]. + const WeekCalendarSelector({ + super.key, + required this.calendarDays, + required this.selectedDate, + required this.shifts, + required this.onDateSelected, + required this.onPreviousWeek, + required this.onNextWeek, + }); + + /// The 7 days to display in the calendar row. + final List calendarDays; + + /// The currently selected date. + final DateTime selectedDate; + + /// Assigned shifts used to show dot indicators on days with shifts. + final List shifts; + + /// Called when a day cell is tapped. + final ValueChanged onDateSelected; + + /// Called when the previous-week chevron is tapped. + final VoidCallback onPreviousWeek; + + /// Called when the next-week chevron is tapped. + final VoidCallback onNextWeek; + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Widget build(BuildContext context) { + final DateTime weekStartDate = calendarDays.first; + + return Container( + color: UiColors.white, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + horizontal: UiConstants.space4, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onPreviousWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + Text( + DateFormat('MMMM yyyy').format(weekStartDate), + style: UiTypography.title1m.textPrimary, + ), + IconButton( + icon: const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.textPrimary, + ), + onPressed: onNextWeek, + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + ), + ], + ), + ), + // Days Grid + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: calendarDays.map((DateTime date) { + final bool isSelected = _isSameDay(date, selectedDate); + final bool hasShifts = shifts.any( + (AssignedShift s) => _isSameDay(s.date, date), + ); + + return GestureDetector( + onTap: () => onDateSelected(date), + child: Column( + children: [ + Container( + width: 44, + height: 60, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all( + color: + isSelected ? UiColors.primary : UiColors.border, + width: 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + date.day.toString().padLeft(2, '0'), + style: isSelected + ? UiTypography.body1b.white + : UiTypography.body1b.textPrimary, + ), + Text( + DateFormat('E').format(date), + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary) + .copyWith( + color: isSelected + ? UiColors.white.withValues(alpha: 0.8) + : null, + ), + ), + if (hasShifts && !isSelected) + Container( + margin: const EdgeInsets.only( + top: UiConstants.space1, + ), + width: 4, + height: 4, + decoration: const BoxDecoration( + color: UiColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index aeed0436..8476744a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -1,18 +1,17 @@ -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' show ReadContext; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; -import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; -import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; +import 'my_shifts/shift_section_list.dart'; +import 'my_shifts/week_calendar_selector.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. +/// +/// Manages the calendar selection state and delegates rendering to +/// [WeekCalendarSelector] and [ShiftSectionList]. class MyShiftsTab extends StatefulWidget { /// Creates a [MyShiftsTab]. const MyShiftsTab({ @@ -133,270 +132,32 @@ class _MyShiftsTabState extends State { return Column( children: [ - // Calendar Selector - Container( - color: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - horizontal: UiConstants.space4, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - UiIcons.chevronLeft, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset--; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - Text( - DateFormat('MMMM yyyy').format(weekStartDate), - style: UiTypography.title1m.textPrimary, - ), - IconButton( - icon: const Icon( - UiIcons.chevronRight, - size: 20, - color: UiColors.textPrimary, - ), - onPressed: () => setState(() { - _weekOffset++; - _selectedDate = _getCalendarDays().first; - _loadShiftsForCurrentWeek(); - }), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - ), - ], - ), - ), - // Days Grid - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((DateTime date) { - final bool isSelected = _isSameDay(date, _selectedDate); - final bool hasShifts = widget.myShifts.any( - (AssignedShift s) => _isSameDay(s.date, date), - ); - - return GestureDetector( - onTap: () => setState(() => _selectedDate = date), - child: Column( - children: [ - Container( - width: 44, - height: 60, - decoration: BoxDecoration( - color: isSelected - ? UiColors.primary - : UiColors.white, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all( - color: isSelected - ? UiColors.primary - : UiColors.border, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - date.day.toString().padLeft(2, '0'), - style: isSelected - ? UiTypography.body1b.white - : UiTypography.body1b.textPrimary, - ), - Text( - DateFormat('E').format(date), - style: (isSelected - ? UiTypography.footnote2m.white - : UiTypography - .footnote2m.textSecondary) - .copyWith( - color: isSelected - ? UiColors.white - .withValues(alpha: 0.8) - : null, - ), - ), - if (hasShifts && !isSelected) - Container( - margin: const EdgeInsets.only( - top: UiConstants.space1, - ), - width: 4, - height: 4, - decoration: const BoxDecoration( - color: UiColors.primary, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ), - ], - ), + WeekCalendarSelector( + calendarDays: calendarDays, + selectedDate: _selectedDate, + shifts: widget.myShifts, + onDateSelected: (DateTime date) => + setState(() => _selectedDate = date), + onPreviousWeek: () => setState(() { + _weekOffset--; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), + onNextWeek: () => setState(() { + _weekOffset++; + _selectedDate = _getCalendarDays().first; + _loadShiftsForCurrentWeek(); + }), ), const Divider(height: 1, color: UiColors.border), - - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - const SizedBox(height: UiConstants.space5), - if (widget.pendingAssignments.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.awaiting, - UiColors.textWarning, - ), - ...widget.pendingAssignments.map( - (PendingAssignment assignment) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromPending(assignment), - onTap: () => Modular.to - .toShiftDetailsById(assignment.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.cancelled, - UiColors.textSecondary, - ), - ...visibleCancelledShifts.map( - (CancelledShift cs) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - child: ShiftCard( - data: ShiftCardData.fromCancelled(cs), - onTap: () => - Modular.to.toShiftDetailsById(cs.shiftId), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - ], - - // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader( - context - .t.staff_shifts.my_shifts_tab.sections.confirmed, - UiColors.textSecondary, - ), - ...visibleMyShifts.map( - (AssignedShift shift) { - final bool isCompleted = - shift.status == AssignmentStatus.completed; - final bool isSubmitted = - widget.submittedShiftIds.contains(shift.shiftId); - final bool isSubmitting = - widget.submittingShiftId == shift.shiftId; - - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: ShiftCard( - data: ShiftCardData.fromAssigned(shift), - onTap: () => Modular.to - .toShiftDetailsById(shift.shiftId), - showApprovalAction: isCompleted, - isSubmitted: isSubmitted, - isSubmitting: isSubmitting, - onSubmitForApproval: () { - ReadContext(context).read().add( - SubmitForApprovalEvent( - shiftId: shift.shiftId, - ), - ); - UiSnackbar.show( - context, - message: context.t.staff_shifts - .my_shift_card.timesheet_submitted, - type: UiSnackbarType.success, - ); - }, - ), - ); - }, - ), - ], - - if (visibleMyShifts.isEmpty && - widget.pendingAssignments.isEmpty && - widget.cancelledShifts.isEmpty) - EmptyStateView( - icon: UiIcons.calendar, - title: - context.t.staff_shifts.my_shifts_tab.empty.title, - subtitle: context - .t.staff_shifts.my_shifts_tab.empty.subtitle, - ), - - const SizedBox(height: UiConstants.space32), - ], - ), - ), + ShiftSectionList( + assignedShifts: visibleMyShifts, + pendingAssignments: widget.pendingAssignments, + cancelledShifts: visibleCancelledShifts, + submittedShiftIds: widget.submittedShiftIds, + submittingShiftId: widget.submittingShiftId, ), ], ); } - - Widget _buildSectionHeader(String title, Color dotColor) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - title, - style: (dotColor == UiColors.textSecondary - ? UiTypography.body2b.textSecondary - : UiTypography.body2b.copyWith(color: dotColor)), - ), - ], - ), - ); - } }