feat: Enhance order details page with date range and clock-in/out labels, and improve OrderScheduleSection layout
This commit is contained in:
@@ -85,3 +85,31 @@ History state is cached in BLoC as `Map<String, AsyncValue<List<BenefitHistory>>
|
|||||||
|
|
||||||
Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content.
|
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.
|
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
|
||||||
|
|||||||
@@ -1885,6 +1885,7 @@
|
|||||||
"spots_left": "${count} spot(s) left",
|
"spots_left": "${count} spot(s) left",
|
||||||
"shifts_count": "${count} shift(s)",
|
"shifts_count": "${count} shift(s)",
|
||||||
"schedule_label": "SCHEDULE",
|
"schedule_label": "SCHEDULE",
|
||||||
|
"date_range_label": "Date Range",
|
||||||
"booking_success": "Order booked successfully!",
|
"booking_success": "Order booked successfully!",
|
||||||
"booking_pending": "Your booking is pending approval",
|
"booking_pending": "Your booking is pending approval",
|
||||||
"booking_confirmed": "Your booking has been confirmed!",
|
"booking_confirmed": "Your booking has been confirmed!",
|
||||||
|
|||||||
@@ -1885,6 +1885,7 @@
|
|||||||
"spots_left": "${count} puesto(s) disponible(s)",
|
"spots_left": "${count} puesto(s) disponible(s)",
|
||||||
"shifts_count": "${count} turno(s)",
|
"shifts_count": "${count} turno(s)",
|
||||||
"schedule_label": "HORARIO",
|
"schedule_label": "HORARIO",
|
||||||
|
"date_range_label": "Rango de Fechas",
|
||||||
"booking_success": "\u00a1Orden reservada con \u00e9xito!",
|
"booking_success": "\u00a1Orden reservada con \u00e9xito!",
|
||||||
"booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n",
|
"booking_pending": "Tu reserva est\u00e1 pendiente de aprobaci\u00f3n",
|
||||||
"booking_confirmed": "\u00a1Tu reserva ha sido confirmada!",
|
"booking_confirmed": "\u00a1Tu reserva ha sido confirmada!",
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ class _OrderDetailsPageState extends State<OrderDetailsPage> {
|
|||||||
schedule: order.schedule,
|
schedule: order.schedule,
|
||||||
scheduleLabel:
|
scheduleLabel:
|
||||||
context.t.available_orders.schedule_label,
|
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(
|
shiftsCountLabel: t.available_orders.shifts_count(
|
||||||
count: order.schedule.totalShifts,
|
count: order.schedule.totalShifts,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class OrderDetailsBottomBar extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: UiButton.primary(
|
child: UiButton.primary(
|
||||||
onPressed: onBook,
|
onPressed: onBook,
|
||||||
text: t.available_orders.book_order,
|
text: 'Book Shift',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
|
|
||||||
/// A section displaying the schedule for an available order.
|
/// 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 {
|
class OrderScheduleSection extends StatelessWidget {
|
||||||
/// Creates an [OrderScheduleSection].
|
/// Creates an [OrderScheduleSection].
|
||||||
const OrderScheduleSection({
|
const OrderScheduleSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.schedule,
|
required this.schedule,
|
||||||
required this.scheduleLabel,
|
required this.scheduleLabel,
|
||||||
|
required this.dateRangeLabel,
|
||||||
|
required this.clockInLabel,
|
||||||
|
required this.clockOutLabel,
|
||||||
required this.shiftsCountLabel,
|
required this.shiftsCountLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,9 +26,40 @@ class OrderScheduleSection extends StatelessWidget {
|
|||||||
/// Localised section title (e.g. "SCHEDULE").
|
/// Localised section title (e.g. "SCHEDULE").
|
||||||
final String scheduleLabel;
|
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)").
|
/// Localised shifts count text (e.g. "3 shift(s)").
|
||||||
final String shiftsCountLabel;
|
final String shiftsCountLabel;
|
||||||
|
|
||||||
|
/// All seven days in ISO order for the day-of-week row.
|
||||||
|
static const List<DayOfWeek> _allDays = <DayOfWeek>[
|
||||||
|
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<String> _dayLabels = <String>[
|
||||||
|
'M',
|
||||||
|
'T',
|
||||||
|
'W',
|
||||||
|
'T',
|
||||||
|
'F',
|
||||||
|
'S',
|
||||||
|
'S',
|
||||||
|
];
|
||||||
|
|
||||||
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
/// Formats a date-only string (e.g. "2026-03-24") to "Mar 24".
|
||||||
String _formatDateShort(String dateStr) {
|
String _formatDateShort(String dateStr) {
|
||||||
if (dateStr.isEmpty) return '';
|
if (dateStr.isEmpty) return '';
|
||||||
@@ -35,96 +71,149 @@ class OrderScheduleSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a DateTime to a time string (e.g. "9:00am").
|
/// Formats [DateTime] to a time string (e.g. "9:00 AM").
|
||||||
String _formatTime(DateTime dt) {
|
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||||
return DateFormat('h:mma').format(dt).toLowerCase();
|
|
||||||
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final String dateRange =
|
|
||||||
'${_formatDateShort(schedule.startDate)} - ${_formatDateShort(schedule.endDate)}';
|
|
||||||
final String timeRange =
|
|
||||||
'${_formatTime(schedule.firstShiftStartsAt)} - ${_formatTime(schedule.lastShiftEndsAt)}';
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
horizontal: UiConstants.space6,
|
|
||||||
vertical: UiConstants.space4,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
// Section title
|
||||||
Text(
|
Text(
|
||||||
scheduleLabel,
|
scheduleLabel,
|
||||||
style: UiTypography.titleUppercase4b.textSecondary,
|
style: UiTypography.titleUppercase4b.textSecondary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
// Days of week chips
|
|
||||||
if (schedule.daysOfWeek.isNotEmpty) ...<Widget>[
|
|
||||||
Wrap(
|
|
||||||
spacing: UiConstants.space1,
|
|
||||||
runSpacing: UiConstants.space1,
|
|
||||||
children: schedule.daysOfWeek
|
|
||||||
.map((DayOfWeek day) => _buildDayChip(day))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Date range row
|
// Date range row
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.calendar,
|
UiIcons.calendar,
|
||||||
size: 20,
|
size: UiConstants.space5,
|
||||||
color: UiColors.primary,
|
color: UiColors.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
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: <Widget>[
|
||||||
|
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(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
Expanded(
|
||||||
UiIcons.clock,
|
child: _buildTimeBox(clockInLabel, schedule.firstShiftStartsAt),
|
||||||
size: 20,
|
),
|
||||||
color: UiColors.primary,
|
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.space8),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'TOTAL SHIFTS',
|
||||||
|
style: UiTypography.titleUppercase4b.textSecondary,
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
|
||||||
// Shifts count
|
// Shifts count
|
||||||
Text(shiftsCountLabel, style: UiTypography.footnote2r.textSecondary),
|
Text(shiftsCountLabel, style: UiTypography.body1r),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a small chip showing a day-of-week abbreviation.
|
/// Builds a single day-of-week circle.
|
||||||
Widget _buildDayChip(DayOfWeek day) {
|
///
|
||||||
final String label = day.value.isNotEmpty
|
/// Active days are filled with the primary color and white text.
|
||||||
? '${day.value[0]}${day.value.substring(1).toLowerCase()}'
|
/// Inactive days use the background color and secondary text.
|
||||||
: '';
|
Widget _buildDayCircle(DayOfWeek day, String label, bool isActive) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
width: 32,
|
||||||
horizontal: UiConstants.space2,
|
height: 32,
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.primary.withValues(alpha: 0.08),
|
border: Border.all(
|
||||||
borderRadius: UiConstants.radiusSm,
|
color: isActive ? UiColors.primary : UiColors.background,
|
||||||
|
width: 1.5,
|
||||||
),
|
),
|
||||||
|
color: isActive ? UiColors.primary.withAlpha(40) : UiColors.background,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: UiTypography.footnote2m.copyWith(color: UiColors.primary),
|
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: <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user