feat: Implement cross-platform NFC clocking interface with calendar and shift sections

This commit is contained in:
Achintha Isuru
2026-03-19 16:28:29 -04:00
parent eff8bcce57
commit ac9a0b9c9d
4 changed files with 392 additions and 266 deletions

View File

@@ -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: <Widget>[
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),
),
],
),
);
}
}

View File

@@ -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 <String>{},
this.submittingShiftId,
});
/// Confirmed/assigned shifts visible for the selected day.
final List<AssignedShift> assignedShifts;
/// Pending assignments awaiting acceptance.
final List<PendingAssignment> pendingAssignments;
/// Cancelled shifts visible for the selected week.
final List<CancelledShift> cancelledShifts;
/// Set of shift IDs that have been successfully submitted for approval.
final Set<String> 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: <Widget>[
const SizedBox(height: UiConstants.space5),
// Pending assignments section
if (pendingAssignments.isNotEmpty) ...<Widget>[
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) ...<Widget>[
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) ...<Widget>[
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<ShiftsBloc>().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),
],
),
),
);
}
}

View File

@@ -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<DateTime> calendarDays;
/// The currently selected date.
final DateTime selectedDate;
/// Assigned shifts used to show dot indicators on days with shifts.
final List<AssignedShift> shifts;
/// Called when a day cell is tapped.
final ValueChanged<DateTime> 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: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
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: <Widget>[
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: <Widget>[
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(),
),
],
),
);
}
}

View File

@@ -1,18 +1,17 @@
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 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; 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:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/utils/shift_date_utils.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/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; import 'my_shifts/shift_section_list.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart'; import 'my_shifts/week_calendar_selector.dart';
/// Tab displaying the worker's assigned, pending, and cancelled shifts. /// 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 { class MyShiftsTab extends StatefulWidget {
/// Creates a [MyShiftsTab]. /// Creates a [MyShiftsTab].
const MyShiftsTab({ const MyShiftsTab({
@@ -133,270 +132,32 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
return Column( return Column(
children: <Widget>[ children: <Widget>[
// Calendar Selector WeekCalendarSelector(
Container( calendarDays: calendarDays,
color: UiColors.white, selectedDate: _selectedDate,
padding: const EdgeInsets.symmetric( shifts: widget.myShifts,
vertical: UiConstants.space4, onDateSelected: (DateTime date) =>
horizontal: UiConstants.space4, setState(() => _selectedDate = date),
), onPreviousWeek: () => setState(() {
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.textPrimary,
),
onPressed: () => setState(() {
_weekOffset--; _weekOffset--;
_selectedDate = _getCalendarDays().first; _selectedDate = _getCalendarDays().first;
_loadShiftsForCurrentWeek(); _loadShiftsForCurrentWeek();
}), }),
constraints: const BoxConstraints(), onNextWeek: () => setState(() {
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++; _weekOffset++;
_selectedDate = _getCalendarDays().first; _selectedDate = _getCalendarDays().first;
_loadShiftsForCurrentWeek(); _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: <Widget>[
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: <Widget>[
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(),
),
],
),
), ),
const Divider(height: 1, color: UiColors.border), const Divider(height: 1, color: UiColors.border),
ShiftSectionList(
Expanded( assignedShifts: visibleMyShifts,
child: SingleChildScrollView( pendingAssignments: widget.pendingAssignments,
padding: const EdgeInsets.symmetric( cancelledShifts: visibleCancelledShifts,
horizontal: UiConstants.space5, submittedShiftIds: widget.submittedShiftIds,
), submittingShiftId: widget.submittingShiftId,
child: Column(
children: <Widget>[
const SizedBox(height: UiConstants.space5),
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
_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) ...<Widget>[
_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) ...<Widget>[
_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<ShiftsBloc>().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),
],
),
),
), ),
], ],
); );
} }
Widget _buildSectionHeader(String title, Color dotColor) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Row(
children: <Widget>[
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)),
),
],
),
);
}
} }