feat(staff): Refactor Shift Cards & Integrate Google Maps

Refactors MyShiftCard to match prototype design with expandable details, bold typography, and Google Static Maps integration. Updates AppConfig for API keys.
This commit is contained in:
2026-02-16 15:57:27 +05:30
parent 4e1a41ebff
commit 690d4f4213
7 changed files with 544 additions and 107 deletions

View File

@@ -1,3 +1,4 @@
{
"GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU"
}
"GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU",
"GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0"
}

View File

@@ -6,4 +6,7 @@ class AppConfig {
/// The Google Places API key used for address autocomplete functionality.
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY');
/// The Google Maps Static API key used for location preview images.
static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY');
}

View File

@@ -579,40 +579,21 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
final i18n = Translations.of(context).staff_shifts.shift_details;
if (status == 'confirmed') {
return Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => _openCancelDialog(context),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.destructive,
foregroundColor: UiColors.white,
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
elevation: 0,
),
child: Text(i18n.cancel_shift, style: UiTypography.body2b.white),
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,
),
const SizedBox(width: UiConstants.space4),
Expanded(
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),
),
),
],
child: Text(i18n.clock_in, style: UiTypography.body2b.white),
),
);
}
@@ -693,32 +674,4 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
return const SizedBox();
}
void _openCancelDialog(BuildContext context) {
final i18n = Translations.of(context).staff_shifts.shift_details.cancel_dialog;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(i18n.title),
content: Text(i18n.message),
actions: [
TextButton(
onPressed: () => Modular.to.pop(),
child: Text(Translations.of(context).common.cancel),
),
TextButton(
onPressed: () {
Modular.to.pop();
BlocProvider.of<ShiftDetailsBloc>(context).add(
DeclineShiftDetailsEvent(widget.shiftId),
);
},
style: TextButton.styleFrom(
foregroundColor: UiColors.destructive,
),
child: Text(Translations.of(context).common.ok),
),
],
),
);
}
}

View File

@@ -4,27 +4,39 @@ import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart';
import 'shift_location_map.dart';
import 'package:krow_core/core.dart'; // For modular navigation
class MyShiftCard extends StatefulWidget {
final Shift shift;
final bool historyMode;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
final VoidCallback? onRequestSwap;
const MyShiftCard({
super.key,
required this.shift,
this.historyMode = false,
this.onAccept,
this.onDecline,
this.onRequestSwap,
});
@override
State<MyShiftCard> createState() => _MyShiftCardState();
}
class _MyShiftCardState extends State<MyShiftCard> {
class _MyShiftCardState extends State<MyShiftCard> with TickerProviderStateMixin {
bool _isExpanded = false;
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]);
// Date doesn't matter for time formatting
final dt = DateTime(2022, 1, 1, hour, minute);
return DateFormat('h:mm a').format(dt);
} catch (e) {
@@ -65,13 +77,18 @@ class _MyShiftCardState extends State<MyShiftCard> {
}
String _getShiftType() {
if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) {
return t.staff_shifts.filter.long_term;
// Handling potential localization key availability
try {
if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) {
return t.staff_shifts.filter.long_term;
}
if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day;
} catch (_) {
return "One Day";
}
if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) {
return t.staff_shifts.filter.multi_day;
}
return t.staff_shifts.filter.one_day;
}
@override
@@ -86,38 +103,43 @@ class _MyShiftCardState extends State<MyShiftCard> {
String statusText = '';
IconData? statusIcon;
if (status == 'confirmed') {
statusText = t.staff_shifts.status.confirmed;
statusColor = UiColors.textLink;
statusBg = UiColors.primary;
} else if (status == 'checked_in') {
statusText = 'Checked in';
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'pending' || status == 'open') {
statusText = t.staff_shifts.status.act_now;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
} else if (status == 'swap') {
statusText = t.staff_shifts.status.swap_requested;
statusColor = UiColors.textWarning;
statusBg = UiColors.textWarning;
statusIcon = UiIcons.swap;
} else if (status == 'completed') {
statusText = t.staff_shifts.status.completed;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'no_show') {
statusText = t.staff_shifts.status.no_show;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
// Fallback localization if keys missing
// Assuming t.staff_shifts.status.* exists as per previous file content
try {
if (status == 'confirmed') {
statusText = t.staff_shifts.status.confirmed;
statusColor = UiColors.textLink;
statusBg = UiColors.primary;
} else if (status == 'checked_in') {
statusText = 'Checked in';
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'pending' || status == 'open') {
statusText = t.staff_shifts.status.act_now;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
} else if (status == 'swap') {
statusText = t.staff_shifts.status.swap_requested;
statusColor = UiColors.textWarning;
statusBg = UiColors.textWarning;
statusIcon = UiIcons.swap;
} else if (status == 'completed') {
statusText = t.staff_shifts.status.completed;
statusColor = UiColors.textSuccess;
statusBg = UiColors.iconSuccess;
} else if (status == 'no_show') {
statusText = t.staff_shifts.status.no_show;
statusColor = UiColors.destructive;
statusBg = UiColors.destructive;
}
} catch (_) {
statusText = status?.toUpperCase() ?? "";
}
return GestureDetector(
onTap: () {
Modular.to.pushShiftDetails(widget.shift);
},
child: Container(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.white,
@@ -156,8 +178,8 @@ class _MyShiftCardState extends State<MyShiftCard> {
)
else
Container(
width: UiConstants.radiusMdValue,
height: UiConstants.radiusMdValue,
width: 8,
height: 8,
margin: const EdgeInsets.only(right: UiConstants.space2),
decoration: BoxDecoration(
color: statusBg,
@@ -304,12 +326,20 @@ class _MyShiftCardState extends State<MyShiftCard> {
],
),
const SizedBox(height: UiConstants.space1),
Text(
"Showing first schedule...",
style: UiTypography.footnote2r.copyWith(
color: UiColors.primary,
),
// Mock loop for demo purposes, as we don't have all schedule dates in the model
// In real app, we might need to fetch schedule or iterate if model changes
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} ${_formatTime(widget.shift.endTime)}',
style: UiTypography.footnote2r.copyWith(color: UiColors.primary),
),
),
if (widget.shift.durationDays! > 1)
Text(
'... +${widget.shift.durationDays! - 1} more days',
style: UiTypography.footnote2r.copyWith(color: UiColors.primary.withOpacity(0.7)),
)
],
),
] else ...[
@@ -370,9 +400,336 @@ class _MyShiftCardState extends State<MyShiftCard> {
],
),
),
// Expanded Content
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _isExpanded
? Column(
children: [
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stats Row
Row(
children: [
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
"Total",
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${widget.shift.hourlyRate}",
"Hourly Rate",
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildStatCard(
UiIcons.clock,
"${duration}",
"Hours",
),
),
],
),
const SizedBox(height: UiConstants.space5),
// In/Out Time
Row(
children: [
Expanded(
child: _buildTimeBox(
"CLOCK IN TIME",
widget.shift.startTime,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTimeBox(
"CLOCK OUT TIME",
widget.shift.endTime,
),
),
],
),
const SizedBox(height: UiConstants.space5),
// Location
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"LOCATION",
style: UiTypography.footnote2b.copyWith(
color: UiColors.textSecondary,
letterSpacing: 0.5),
),
const SizedBox(height: UiConstants.space2),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.shift.location.isEmpty
? "TBD"
: widget.shift.location,
style: UiTypography.title1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UiConstants.space3),
OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
widget.shift.locationAddress ??
widget.shift.location,
),
duration: const Duration(
seconds: 3,
),
),
);
},
icon: const Icon(
UiIcons.navigation,
size: UiConstants.iconXs,
),
label: const Text(
"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: widget.shift,
height: 128,
borderRadius: UiConstants.radiusBase,
),
],
),
const SizedBox(height: UiConstants.space5),
// Additional Info
if (widget.shift.description != null) ...[
SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
"ADDITIONAL INFO",
style: UiTypography.footnote2b.copyWith(
color: UiColors.textSecondary,
letterSpacing: 0.5),
),
const SizedBox(height: UiConstants.space2),
Text(
widget.shift.description!,
style: UiTypography.body2m.textPrimary,
),
],
),
),
const SizedBox(height: UiConstants.space5),
],
// Actions
if (!widget.historyMode)
Padding(
padding: const EdgeInsets.only(top: UiConstants.space2),
child: _buildActions(status),
),
],
),
),
],
)
: const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildActions(String? status) {
if (status == 'confirmed') {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: widget.onRequestSwap,
icon: const Icon(
UiIcons.swap,
size: UiConstants.iconSm,
),
label: const Text("Request Swap"),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.primary,
side: const BorderSide(
color: UiColors.primary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
),
),
),
);
} else if (status == 'swap') {
return Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: UiColors.tagPending,
border: Border.all(
color: UiColors.textWarning,
),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Icon(
UiIcons.swap,
size: UiConstants.iconSm,
color: UiColors.textWarning,
),
const SizedBox(width: UiConstants.space2),
Text(
"Swap Pending",
style: UiTypography.body2b.copyWith(
color: UiColors.textWarning,
),
),
],
),
);
} else {
// status == 'open' || status == 'pending' or others
return Column(
children: [
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: widget.onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(UiConstants.radiusBase),
),
),
child: widget.onAccept == null
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)
) // Loading state if callback null? or just Text
: const Text(
"Book Shift",
style: TextStyle(
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
}
Widget _buildStatCard(IconData icon, String value, String label) {
return Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all(color: UiColors.border),
),
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.background,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
),
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,103 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:krow_core/core.dart'; // Import AppConfig from krow_core
class ShiftLocationMap extends StatelessWidget {
final Shift shift;
final double height;
final double borderRadius;
const ShiftLocationMap({
super.key,
required this.shift,
this.height = 120,
this.borderRadius = 8,
});
@override
Widget build(BuildContext context) {
if (AppConfig.googleMapsApiKey.isEmpty) {
return _buildPlaceholder(context, "Config Map Key");
}
final String mapUrl = _generateStaticMapUrl();
return Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(borderRadius),
),
clipBehavior: Clip.antiAlias,
child: Image.network(
mapUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildPlaceholder(context, "Map unavailable");
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
),
);
}
String _generateStaticMapUrl() {
// Base URL
const String baseUrl = "https://maps.googleapis.com/maps/api/staticmap";
// Parameters
String center;
if (shift.latitude != null && shift.longitude != null) {
center = "${shift.latitude},${shift.longitude}";
} else {
center = Uri.encodeComponent(shift.locationAddress.isNotEmpty
? shift.locationAddress
: shift.location);
}
// Construct URL
// scale=2 for retina displays
return "$baseUrl?center=$center&zoom=15&size=600x300&maptype=roadmap&markers=color:red%7C$center&key=${AppConfig.googleMapsApiKey}&scale=2";
}
Widget _buildPlaceholder(BuildContext context, String message) {
return Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: UiColors.secondary,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
UiIcons.mapPin,
size: 32,
color: UiColors.iconSecondary,
),
if (message.isNotEmpty) ...[
const SizedBox(height: UiConstants.space2),
Text(
message,
style: UiTypography.footnote2r.textSecondary,
),
],
],
),
),
);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/shifts/shifts_bloc.dart';
import '../my_shift_card.dart';
import '../shared/empty_state_view.dart';
@@ -171,6 +173,14 @@ class _FindShiftsTabState extends State<FindShiftsTab> {
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: MyShiftCard(
shift: shift,
onAccept: () {
context.read<ShiftsBloc>().add(AcceptShiftEvent(shift.id));
UiSnackbar.show(
context,
message: "Shift application submitted!", // Todo: Localization
type: UiSnackbarType.success,
);
},
),
),
),

View File

@@ -382,7 +382,17 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
...visibleMyShifts.map(
(shift) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: MyShiftCard(shift: shift),
child: MyShiftCard(
shift: shift,
onDecline: () => _declineShift(shift.id),
onRequestSwap: () {
UiSnackbar.show(
context,
message: "Swap functionality coming soon!", // Todo: Localization
type: UiSnackbarType.message,
);
},
),
),
),
],