feat(shift-details): Refactor ShiftDetailsPage layout and implement new sections for breaks, date/time, description, and location

This commit is contained in:
Achintha Isuru
2026-02-16 13:38:39 -05:00
parent 2f9b2788f8
commit 0b787dbc12
8 changed files with 727 additions and 480 deletions

View File

@@ -10,7 +10,13 @@ import 'package:krow_domain/krow_domain.dart';
import '../blocs/shift_details/shift_details_bloc.dart';
import '../blocs/shift_details/shift_details_event.dart';
import '../blocs/shift_details/shift_details_state.dart';
import '../widgets/shift_location_map.dart';
import '../widgets/shift_details/shift_break_section.dart';
import '../widgets/shift_details/shift_date_time_section.dart';
import '../widgets/shift_details/shift_description_section.dart';
import '../widgets/shift_details/shift_details_bottom_bar.dart';
import '../widgets/shift_details/shift_details_header.dart';
import '../widgets/shift_details/shift_location_section.dart';
import '../widgets/shift_details/shift_stats_row.dart';
class ShiftDetailsPage extends StatefulWidget {
final String shiftId;
@@ -68,65 +74,6 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
}
}
Widget _buildStatCard(IconData icon, String value, String label) {
return Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgThird,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Column(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.white,
shape: BoxShape.circle,
),
child: Icon(icon, size: 20, color: UiColors.textSecondary),
),
const SizedBox(height: UiConstants.space2),
Text(
value,
style: UiTypography.title1m
.copyWith(fontWeight: FontWeight.w700)
.textPrimary,
),
Text(label, style: UiTypography.footnote2r.textSecondary),
],
),
);
}
Widget _buildTimeBox(String label, String 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,
),
],
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftDetailsBloc>(
@@ -134,7 +81,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
..add(
LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift.roleId),
),
child: BlocListener<ShiftDetailsBloc, ShiftDetailsState>(
child: BlocConsumer<ShiftDetailsBloc, ShiftDetailsState>(
listener: (context, state) {
if (state is ShiftActionSuccess || state is ShiftDetailsError) {
_closeActionDialog(context);
@@ -158,345 +105,99 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
_isApplying = false;
}
},
child: BlocBuilder<ShiftDetailsBloc, ShiftDetailsState>(
builder: (context, state) {
if (state is ShiftDetailsLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
builder: (context, state) {
if (state is ShiftDetailsLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
Shift? displayShift = widget.shift;
Shift displayShift = widget.shift;
final i18n = Translations.of(context).staff_shifts.shift_details;
final i18n = Translations.of(context).staff_shifts.shift_details;
final duration = _calculateDuration(displayShift);
final estimatedTotal =
displayShift.totalValue ?? (displayShift.hourlyRate * duration);
final duration = _calculateDuration(displayShift);
final estimatedTotal =
displayShift.totalValue ?? (displayShift.hourlyRate * duration);
return Scaffold(
appBar: UiAppBar(
centerTitle: false,
onLeadingPressed: () => Modular.to.toShifts(),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Role & Client Section
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.border),
),
child: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 24,
),
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
displayShift.title,
style:
UiTypography.headline1b.textPrimary,
),
Text(
displayShift.clientName,
style:
UiTypography.body1m.textSecondary,
),
Text(
displayShift.locationAddress,
style:
UiTypography.body2r.textSecondary,
),
],
),
),
],
),
),
const Divider(height: 1, thickness: 0.5),
// Stats Row (New)
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
children: [
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
i18n.est_total,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${displayShift.hourlyRate.toStringAsFixed(0)}",
i18n.hourly_rate,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildStatCard(
UiIcons.clock,
duration.toStringAsFixed(1),
i18n.hours,
),
),
],
),
),
const Divider(height: 1, thickness: 0.5),
// Date & Time Section
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.shift_date,
style: UiTypography
.titleUppercase4b
.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
_formatDate(displayShift.date),
style:
UiTypography.headline5m.textPrimary,
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: [
Expanded(
child: _buildTimeBox(
i18n.start_time,
displayShift.startTime,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildTimeBox(
i18n.end_time,
displayShift.endTime,
),
),
],
),
],
),
),
const Divider(height: 1, thickness: 0.5),
// Break Section
if (displayShift.breakInfo != null &&
displayShift.breakInfo!.duration !=
BreakDuration.none) ...[
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.break_title,
style: UiTypography.titleUppercase4b
.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.breakIcon,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
"${displayShift.breakInfo!.duration.minutes} ${i18n.min} (${displayShift.breakInfo!.isBreakPaid ? i18n.paid : i18n.unpaid})",
style:
UiTypography.headline5m.textPrimary,
),
],
),
],
),
),
const Divider(height: 1, thickness: 0.5),
],
// Location Section (New with Map)
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.location,
style: UiTypography
.titleUppercase4b
.textSecondary,
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
displayShift.location.isEmpty
? i18n.tbd
: displayShift.location,
style: UiTypography.title1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UiConstants.space3),
OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
displayShift
.locationAddress
.isNotEmpty
? displayShift.locationAddress
: displayShift.location,
),
duration: const Duration(
seconds: 3,
),
),
);
},
icon: const Icon(
UiIcons.navigation,
size: UiConstants.iconXs,
),
label: Text(i18n.get_direction),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(
color: UiColors.border,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: 0,
),
minimumSize: const Size(0, 32),
),
),
],
),
const SizedBox(height: UiConstants.space3),
ShiftLocationMap(
shift: displayShift,
height: 160,
borderRadius: UiConstants.radiusBase,
),
],
),
),
const Divider(height: 1, thickness: 0.5),
// Description / Instructions
if ((displayShift.description ?? '').isNotEmpty) ...[
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
i18n.job_description,
style: UiTypography
.titleUppercase4b
.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Text(
displayShift.description!,
style: UiTypography.body2r.textSecondary,
),
],
),
),
],
],
),
),
),
// Bottom Action Bar
Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
MediaQuery.of(context).padding.bottom +
UiConstants.space4,
),
decoration: BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
boxShadow: [
BoxShadow(
color: UiColors.popupShadow.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -4),
return Scaffold(
appBar: UiAppBar(
centerTitle: false,
onLeadingPressed: () => Modular.to.toShifts(),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShiftDetailsHeader(shift: displayShift),
const Divider(height: 1, thickness: 0.5),
ShiftStatsRow(
estimatedTotal: estimatedTotal,
hourlyRate: displayShift.hourlyRate,
duration: duration,
totalLabel: i18n.est_total,
hourlyRateLabel: i18n.hourly_rate,
hoursLabel: i18n.hours,
),
const Divider(height: 1, thickness: 0.5),
ShiftDateTimeSection(
date: displayShift.date,
startTime: displayShift.startTime,
endTime: displayShift.endTime,
shiftDateLabel: i18n.shift_date,
clockInLabel: i18n.start_time,
clockOutLabel: i18n.end_time,
),
const Divider(height: 1, thickness: 0.5),
if (displayShift.breakInfo != null &&
displayShift.breakInfo!.duration !=
BreakDuration.none) ...[
ShiftBreakSection(
breakInfo: displayShift.breakInfo!,
breakTitle: i18n.break_title,
paidLabel: i18n.paid,
unpaidLabel: i18n.unpaid,
minLabel: i18n.min,
),
const Divider(height: 1, thickness: 0.5),
],
ShiftLocationSection(
shift: displayShift,
locationLabel: i18n.location,
tbdLabel: i18n.tbd,
getDirectionLabel: i18n.get_direction,
),
const Divider(height: 1, thickness: 0.5),
if (displayShift.description != null &&
displayShift.description!.isNotEmpty)
ShiftDescriptionSection(
description: displayShift.description!,
descriptionLabel: i18n.job_description,
),
],
),
child: _buildBottomButton(displayShift, context),
),
],
),
);
},
),
),
ShiftDetailsBottomBar(
shift: displayShift,
onApply: () => _bookShift(context, displayShift),
onDecline: () => BlocProvider.of<ShiftDetailsBloc>(
context,
).add(DeclineShiftDetailsEvent(displayShift.id)),
onAccept: () =>
BlocProvider.of<ShiftDetailsBloc>(context).add(
BookShiftDetailsEvent(
displayShift.id,
roleId: displayShift.roleId,
),
),
),
],
),
);
},
),
);
}
@@ -591,91 +292,4 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
Navigator.of(context, rootNavigator: true).pop();
_actionDialogOpen = false;
}
Widget _buildBottomButton(Shift shift, BuildContext context) {
final String status = shift.status ?? 'open';
final i18n = Translations.of(context).staff_shifts.shift_details;
if (status == 'confirmed') {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Modular.to.toClockIn(),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.success,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.clock_in, style: UiTypography.body2b.white),
),
);
}
if (status == 'pending') {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => BlocProvider.of<ShiftDetailsBloc>(
context,
).add(DeclineShiftDetailsEvent(shift.id)),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
side: const BorderSide(color: UiColors.destructive),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
),
child: Text(i18n.decline, style: UiTypography.body2b.textError),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: ElevatedButton(
onPressed: () => BlocProvider.of<ShiftDetailsBloc>(
context,
).add(BookShiftDetailsEvent(shift.id, roleId: shift.roleId)),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.accept_shift, style: UiTypography.body2b.white),
),
),
],
);
}
if (status == 'open' || status == 'available') {
return ElevatedButton(
onPressed: () => _bookShift(context, shift),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.apply_now, style: UiTypography.body2b.white),
);
}
return const SizedBox();
}
}

View File

@@ -0,0 +1,62 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// A section displaying shift break details (duration and payment status).
class ShiftBreakSection extends StatelessWidget {
/// The break information.
final Break breakInfo;
/// Localization string for break section title.
final String breakTitle;
/// Localization string for paid status.
final String paidLabel;
/// Localization string for unpaid status.
final String unpaidLabel;
/// Localization string for minutes ("min").
final String minLabel;
/// Creates a [ShiftBreakSection].
const ShiftBreakSection({
super.key,
required this.breakInfo,
required this.breakTitle,
required this.paidLabel,
required this.unpaidLabel,
required this.minLabel,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
breakTitle,
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.breakIcon,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
"${breakInfo.duration.minutes} $minLabel (${breakInfo.isBreakPaid ? paidLabel : unpaidLabel})",
style: UiTypography.headline5m.textPrimary,
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// A section displaying the date and the shift's start/end times.
class ShiftDateTimeSection extends StatelessWidget {
/// The ISO string of the date.
final String date;
/// The start time string (HH:mm).
final String startTime;
/// The end time string (HH:mm).
final String endTime;
/// Localization string for shift date.
final String shiftDateLabel;
/// Localization string for clock in time.
final String clockInLabel;
/// Localization string for clock out time.
final String clockOutLabel;
/// Creates a [ShiftDateTimeSection].
const ShiftDateTimeSection({
super.key,
required this.date,
required this.startTime,
required this.endTime,
required this.shiftDateLabel,
required this.clockInLabel,
required this.clockOutLabel,
});
String _formatTime(String time) {
if (time.isEmpty) return '';
try {
final parts = time.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
return time;
}
}
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
return DateFormat('EEEE, MMMM d, y').format(date);
} catch (e) {
return dateStr;
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shiftDateLabel,
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
Text(
_formatDate(date),
style: UiTypography.headline5m.textPrimary,
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: [
Expanded(
child: _buildTimeBox(
clockInLabel,
startTime,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildTimeBox(
clockOutLabel,
endTime,
),
),
],
),
],
),
);
}
Widget _buildTimeBox(String label, String 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,
),
],
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A section displaying the job description for the shift.
class ShiftDescriptionSection extends StatelessWidget {
/// The description text.
final String description;
/// Localization string for description section title.
final String descriptionLabel;
/// Creates a [ShiftDescriptionSection].
const ShiftDescriptionSection({
super.key,
required this.description,
required this.descriptionLabel,
});
@override
Widget build(BuildContext context) {
if (description.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
descriptionLabel,
style: UiTypography.titleUppercase4b.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Text(
description,
style: UiTypography.body2r.textSecondary,
),
],
),
);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart';
/// A bottom action bar containing contextual buttons based on shift status.
class ShiftDetailsBottomBar extends StatelessWidget {
/// The current shift.
final Shift shift;
/// Callback for applying/booking a shift.
final VoidCallback onApply;
/// Callback for declining a shift.
final VoidCallback onDecline;
/// Callback for accepting a shift.
final VoidCallback onAccept;
/// Creates a [ShiftDetailsBottomBar].
const ShiftDetailsBottomBar({
super.key,
required this.shift,
required this.onApply,
required this.onDecline,
required this.onAccept,
});
@override
Widget build(BuildContext context) {
final String status = shift.status ?? 'open';
final i18n = Translations.of(context).staff_shifts.shift_details;
return Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
MediaQuery.of(context).padding.bottom + UiConstants.space4,
),
decoration: BoxDecoration(
color: UiColors.white,
border: Border(top: BorderSide(color: UiColors.border)),
boxShadow: [
BoxShadow(
color: UiColors.popupShadow.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -4),
),
],
),
child: _buildButtons(status, i18n, context),
);
}
Widget _buildButtons(String status, dynamic i18n, BuildContext context) {
if (status == 'confirmed') {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Modular.to.toClockIn(),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.success,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.clock_in, style: UiTypography.body2b.white),
),
);
}
if (status == 'pending') {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
side: const BorderSide(color: UiColors.destructive),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
),
child: Text(i18n.decline, style: UiTypography.body2b.textError),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: ElevatedButton(
onPressed: onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.accept_shift, style: UiTypography.body2b.white),
),
),
],
);
}
if (status == 'open' || status == 'available') {
return ElevatedButton(
onPressed: onApply,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.apply_now, style: UiTypography.body2b.white),
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,65 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// A header widget for the shift details page displaying the role, client name, and address.
class ShiftDetailsHeader extends StatelessWidget {
/// The shift entity containing the header information.
final Shift shift;
/// Creates a [ShiftDetailsHeader].
const ShiftDetailsHeader({
super.key,
required this.shift,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.border),
),
child: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 24,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.title,
style: UiTypography.headline1b.textPrimary,
),
Text(
shift.clientName,
style: UiTypography.body1m.textSecondary,
),
Text(
shift.locationAddress,
style: UiTypography.body2r.textSecondary,
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import '../shift_location_map.dart';
/// A section displaying the shift's location, address, map, and "Get direction" action.
class ShiftLocationSection extends StatelessWidget {
/// The shift entity containing location data.
final Shift shift;
/// Localization string for location section title.
final String locationLabel;
/// Localization string for "TBD".
final String tbdLabel;
/// Localization string for "Get direction".
final String getDirectionLabel;
/// Creates a [ShiftLocationSection].
const ShiftLocationSection({
super.key,
required this.shift,
required this.locationLabel,
required this.tbdLabel,
required this.getDirectionLabel,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locationLabel,
style: UiTypography.titleUppercase4b.textSecondary,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
shift.location.isEmpty ? tbdLabel : shift.location,
style: UiTypography.title1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UiConstants.space3),
OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
shift.locationAddress.isNotEmpty
? shift.locationAddress
: shift.location,
),
duration: const Duration(seconds: 3),
),
);
},
icon: const Icon(
UiIcons.navigation,
size: UiConstants.iconXs,
),
label: Text(getDirectionLabel),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(color: UiColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: 0,
),
minimumSize: const Size(0, 32),
),
),
],
),
const SizedBox(height: UiConstants.space3),
ShiftLocationMap(
shift: shift,
height: 160,
borderRadius: UiConstants.radiusBase,
),
],
),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A row of statistic cards for shift details (Total Pay, Rate, Hours).
class ShiftStatsRow extends StatelessWidget {
/// Estimated total pay for the shift.
final double estimatedTotal;
/// Hourly rate for the shift.
final double hourlyRate;
/// Total duration of the shift in hours.
final double duration;
/// Localization string for total.
final String totalLabel;
/// Localization string for hourly rate.
final String hourlyRateLabel;
/// Localization string for hours.
final String hoursLabel;
/// Creates a [ShiftStatsRow].
const ShiftStatsRow({
super.key,
required this.estimatedTotal,
required this.hourlyRate,
required this.duration,
required this.totalLabel,
required this.hourlyRateLabel,
required this.hoursLabel,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
children: [
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
totalLabel,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${hourlyRate.toStringAsFixed(0)}",
hourlyRateLabel,
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: _buildStatCard(
UiIcons.clock,
duration.toStringAsFixed(1),
hoursLabel,
),
),
],
),
);
}
Widget _buildStatCard(IconData icon, String value, String label) {
return Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgThird,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Column(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: UiColors.white,
shape: BoxShape.circle,
),
child: Icon(icon, size: 20, color: UiColors.textSecondary),
),
const SizedBox(height: UiConstants.space2),
Text(
value,
style: UiTypography.title1m
.copyWith(fontWeight: FontWeight.w700)
.textPrimary,
),
Text(label, style: UiTypography.footnote2r.textSecondary),
],
),
);
}
}