From 690d4f4213e0dfd6ef81948e5ccaa7cdd15806ca Mon Sep 17 00:00:00 2001 From: Suriya Date: Mon, 16 Feb 2026 15:57:27 +0530 Subject: [PATCH] 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. --- apps/mobile/config.dev.json | 5 +- .../core/lib/src/config/app_config.dart | 3 + .../pages/shift_details_page.dart | 73 +-- .../presentation/widgets/my_shift_card.dart | 445 ++++++++++++++++-- .../widgets/shift_location_map.dart | 103 ++++ .../widgets/tabs/find_shifts_tab.dart | 10 + .../widgets/tabs/my_shifts_tab.dart | 12 +- 7 files changed, 544 insertions(+), 107 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index d2d5fa4c..214cb535 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,3 +1,4 @@ { - "GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU" -} + "GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU", + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" +} \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index ac1ccfc7..727638a4 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -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'); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 819293cc..6c83272b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -579,40 +579,21 @@ class _ShiftDetailsPageState extends State { 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 { 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(context).add( - DeclineShiftDetailsEvent(widget.shiftId), - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(Translations.of(context).common.ok), - ), - ], - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index d6ee5fa1..bbf5eb35 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -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 createState() => _MyShiftCardState(); } -class _MyShiftCardState extends State { +class _MyShiftCardState extends State 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 { } 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 { 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 { ) 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 { ], ), 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 { ], ), ), + + // 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, + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart new file mode 100644 index 00000000..d5f8dc35 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart @@ -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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 6422c312..95d1f7db 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -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 { padding: const EdgeInsets.only(bottom: UiConstants.space3), child: MyShiftCard( shift: shift, + onAccept: () { + context.read().add(AcceptShiftEvent(shift.id)); + UiSnackbar.show( + context, + message: "Shift application submitted!", // Todo: Localization + type: UiSnackbarType.success, + ); + }, ), ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index e17654e3..51472ec9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -382,7 +382,17 @@ class _MyShiftsTabState extends State { ...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, + ); + }, + ), ), ), ],