diff --git a/.claude/agent-memory/ui-ux-design/component-patterns.md b/.claude/agent-memory/ui-ux-design/component-patterns.md index 9ce4d7c2..6d50b939 100644 --- a/.claude/agent-memory/ui-ux-design/component-patterns.md +++ b/.claude/agent-memory/ui-ux-design/component-patterns.md @@ -85,3 +85,31 @@ History state is cached in BLoC as `Map> Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content. Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar. + +## ShiftDateTimeSection / OrderScheduleSection — Shift Detail Section Pattern + +Both widgets live in `packages/features/staff/shifts/lib/src/presentation/widgets/`: +- `shift_details/shift_date_time_section.dart` — single date, clock-in/clock-out boxes +- `order_details/order_schedule_section.dart` — date range, 7-day circle row, clock-in/clock-out boxes + +**Shared conventions (non-negotiable for section consistency):** +- Outer padding: `EdgeInsets.all(UiConstants.space5)` — 20dp all sides +- Section title: `UiTypography.titleUppercase4b.textSecondary` +- Title → content gap: `UiConstants.space2` (8dp) +- Time boxes: `UiColors.bgThird` background, `UiConstants.radiusBase` (12dp) corners, `UiConstants.space3` (12dp) all padding +- Time box label: `UiTypography.footnote2b.copyWith(color: UiColors.textSecondary, letterSpacing: 0.5)` +- Time box value: `UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary` +- Between time boxes: `UiConstants.space4` (16dp) gap +- Date → time boxes gap: `UiConstants.space6` (24dp) +- Time format: `DateFormat('h:mm a')` — uppercase AM/PM with space + +**OrderScheduleSection day-of-week circles:** +- 7 circles always shown (Mon–Sun ISO order) regardless of active days +- Circle size: 32×32dp (fixed, not a token) +- Active: bg=`UiColors.primary`, text=`UiColors.white`, style=`footnote2m` +- Inactive: bg=`UiColors.bgThird`, text=`UiColors.textSecondary`, style=`footnote2m` +- Shape: `UiConstants.radiusFull` +- Single-char labels: M T W T F S S +- Inter-circle gap: `UiConstants.space2` (8dp) +- Accessibility: wrap row with `Semantics(label: "Repeats on ...")`, mark individual circles with `ExcludeSemantics` +- Ordering constant: `[DayOfWeek.mon, .tue, .wed, .thu, .fri, .sat, .sun]` — do NOT derive from API list order diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 423ea826..7088c0e7 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} spot(s) left", "shifts_count": "${count} shift(s)", "schedule_label": "SCHEDULE", + "date_range_label": "Date Range", "booking_success": "Order booked successfully!", "booking_pending": "Your booking is pending approval", "booking_confirmed": "Your booking has been confirmed!", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 927d701c..718eeb72 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1885,6 +1885,7 @@ "spots_left": "${count} puesto(s) disponible(s)", "shifts_count": "${count} turno(s)", "schedule_label": "HORARIO", + "date_range_label": "Rango de Fechas", "booking_success": "\u00a1Orden reservada con \u00e9xito!", "booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n", "booking_confirmed": "\u00a1Tu reserva ha sido confirmada!", diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart index ffc0debd..3512f336 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/order_details_page.dart @@ -139,6 +139,12 @@ class _OrderDetailsPageState extends State { schedule: order.schedule, scheduleLabel: context.t.available_orders.schedule_label, + dateRangeLabel: + context.t.available_orders.date_range_label, + clockInLabel: + context.t.staff_shifts.shift_details.start_time, + clockOutLabel: + context.t.staff_shifts.shift_details.end_time, shiftsCountLabel: t.available_orders.shifts_count( count: order.schedule.totalShifts, ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart index e97de4c2..80a5d44b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_details_bottom_bar.dart @@ -79,7 +79,7 @@ class OrderDetailsBottomBar extends StatelessWidget { width: double.infinity, child: UiButton.primary( onPressed: onBook, - text: t.available_orders.book_order, + text: 'Book Shift', ), ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart index a7cbdfda..a961d795 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/order_details/order_schedule_section.dart @@ -5,13 +5,18 @@ import 'package:krow_domain/krow_domain.dart'; /// A section displaying the schedule for an available order. /// -/// Shows the days-of-week chips, date range, time range, and total shift count. +/// Shows a date range, Google Calendar-style day-of-week circles, +/// clock-in/clock-out time boxes, and total shift count. +/// Follows the same visual structure as [ShiftDateTimeSection]. class OrderScheduleSection extends StatelessWidget { /// Creates an [OrderScheduleSection]. const OrderScheduleSection({ super.key, required this.schedule, required this.scheduleLabel, + required this.dateRangeLabel, + required this.clockInLabel, + required this.clockOutLabel, required this.shiftsCountLabel, }); @@ -21,9 +26,40 @@ class OrderScheduleSection extends StatelessWidget { /// Localised section title (e.g. "SCHEDULE"). final String scheduleLabel; + /// Localised label for the date range row (e.g. "Date Range"). + final String dateRangeLabel; + + /// Localised label for the clock-in time box (e.g. "START TIME"). + final String clockInLabel; + + /// Localised label for the clock-out time box (e.g. "END TIME"). + final String clockOutLabel; + /// Localised shifts count text (e.g. "3 shift(s)"). final String shiftsCountLabel; + /// All seven days in ISO order for the day-of-week row. + static const List _allDays = [ + DayOfWeek.mon, + DayOfWeek.tue, + DayOfWeek.wed, + DayOfWeek.thu, + DayOfWeek.fri, + DayOfWeek.sat, + DayOfWeek.sun, + ]; + + /// Single-letter labels for each day (ISO order). + static const List _dayLabels = [ + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + 'S', + ]; + /// Formats a date-only string (e.g. "2026-03-24") to "Mar 24". String _formatDateShort(String dateStr) { if (dateStr.isEmpty) return ''; @@ -35,96 +71,149 @@ class OrderScheduleSection extends StatelessWidget { } } - /// Formats a DateTime to a time string (e.g. "9:00am"). - String _formatTime(DateTime dt) { - return DateFormat('h:mma').format(dt).toLowerCase(); + /// Formats [DateTime] to a time string (e.g. "9:00 AM"). + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + /// Builds the date range display string including the year. + String _buildDateRangeText() { + final String start = _formatDateShort(schedule.startDate); + final String end = _formatDateShort(schedule.endDate); + // Extract year from endDate for display. + String year = ''; + if (schedule.endDate.isNotEmpty) { + try { + final DateTime endDt = DateTime.parse(schedule.endDate); + year = ', ${endDt.year}'; + } catch (_) { + // Ignore parse errors. + } + } + return '$start - $end$year'; } @override Widget build(BuildContext context) { - final String dateRange = - '${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}'; - final String timeRange = - '${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}'; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space6, - vertical: UiConstants.space4, - ), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section title Text( scheduleLabel, style: UiTypography.titleUppercase4b.textSecondary, ), - const SizedBox(height: UiConstants.space3), - - // Days of week chips - if (schedule.daysOfWeek.isNotEmpty) ...[ - Wrap( - spacing: UiConstants.space1, - runSpacing: UiConstants.space1, - children: schedule.daysOfWeek - .map((DayOfWeek day) => _buildDayChip(day)) - .toList(), - ), - const SizedBox(height: UiConstants.space3), - ], + const SizedBox(height: UiConstants.space4), // Date range row Row( children: [ const Icon( UiIcons.calendar, - size: 20, - color: UiColors.primary, + size: UiConstants.space5, + color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), - Text(dateRange, style: UiTypography.headline5m.textPrimary), + Text( + _buildDateRangeText(), + style: UiTypography.title1m.textPrimary, + ), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space6), - // Time range row + // Days-of-week circles (Google Calendar style) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int i = 0; i < _allDays.length; i++) + _buildDayCircle( + _allDays[i], + _dayLabels[i], + schedule.daysOfWeek.contains(_allDays[i]), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Clock in / Clock out time boxes Row( children: [ - const Icon( - UiIcons.clock, - size: 20, - color: UiColors.primary, + Expanded( + child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeBox(clockOutLabel, schedule.lastShiftEndsAt), ), - const SizedBox(width: UiConstants.space2), - Text(timeRange, style: UiTypography.headline5m.textPrimary), ], ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space8), + Text( + 'TOTAL SHIFTS', + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), // Shifts count - Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary), + Text(shiftsCountLabel, style: UiTypography.body1r), ], ), ); } - /// Builds a small chip showing a day-of-week abbreviation. - Widget _buildDayChip(DayOfWeek day) { - final String label = day.value.isNotEmpty - ? '${day.value[0]}${day.value.substring(1).toLowerCase()}' - : ''; + /// Builds a single day-of-week circle. + /// + /// Active days are filled with the primary color and white text. + /// Inactive days use the background color and secondary text. + Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) { return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), + width: 32, + height: 32, decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: UiConstants.radiusSm, + border: Border.all( + color: isActive ? UiColors.primary : UiColors.background, + width: 1.5, + ), + color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background, + shape: BoxShape.circle, ), - child: Text( - label, - style: UiTypography.footnote2m.copyWith(color: UiColors.primary), + child: Center( + child: Text( + label, + style: isActive + ? UiTypography.footnote1b.primary + : UiTypography.footnote2m.textSecondary, + ), + ), + ); + } + + /// Builds a time-display box matching the [ShiftDateTimeSection] pattern. + Widget _buildTimeBox(String label, DateTime time) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + _formatTime(time), + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, + ), + ], ), ); }