diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index 2b3d3669..6b04f468 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -163,6 +163,12 @@ class UiIcons { /// Eye off icon for hidden visibility static const IconData eyeOff = _IconLib.eyeOff; + /// Phone icon for calls + static const IconData phone = _IconLib.phone; + + /// Message circle icon for chat + static const IconData messageCircle = _IconLib.messageCircle; + /// Building icon for companies static const IconData building = _IconLib.building2; diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index 47252d81..92e295b2 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -12,8 +12,8 @@ class UiTheme { /// Returns the light theme for the Staff application. static ThemeData get light { - final colorScheme = UiColors.colorScheme; - final textTheme = UiTypography.textTheme; + final ColorScheme colorScheme = UiColors.colorScheme; + final TextTheme textTheme = UiTypography.textTheme; return ThemeData( useMaterial3: true, @@ -68,7 +68,6 @@ class UiTheme { horizontal: UiConstants.space6, vertical: UiConstants.space3, ), - minimumSize: const Size(double.infinity, 54), maximumSize: const Size(double.infinity, 54), ).copyWith( side: WidgetStateProperty.resolveWith((states) { @@ -99,7 +98,6 @@ class UiTheme { horizontal: UiConstants.space4, vertical: UiConstants.space2, ), - minimumSize: const Size(double.infinity, 52), maximumSize: const Size(double.infinity, 52), ), ), @@ -117,7 +115,6 @@ class UiTheme { horizontal: UiConstants.space4, vertical: UiConstants.space3, ), - minimumSize: const Size(double.infinity, 52), maximumSize: const Size(double.infinity, 52), ), ), diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 9f3d5b99..dc795923 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -320,6 +320,15 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Body 3 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: -0.1 (#121826) + static final TextStyle body3m = _primaryBase.copyWith( + fontWeight: FontWeight.w500, + fontSize: 12, + height: 1.5, + letterSpacing: -0.1, + color: UiColors.textPrimary, + ); + /// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) static final TextStyle body4r = _primaryBase.copyWith( fontWeight: FontWeight.w400, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart index 4f0535c6..1460f07a 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart @@ -27,6 +27,9 @@ class UiButton extends StatelessWidget { /// The size of the button. final UiButtonSize size; + /// Whether the button should take up the full width of its container. + final bool fullWidth; + /// The button widget to use (ElevatedButton, OutlinedButton, or TextButton). final Widget Function( BuildContext context, @@ -48,6 +51,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : assert( text != null || child != null, 'Either text or child must be provided', @@ -64,6 +68,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _elevatedButtonBuilder, assert( text != null || child != null, @@ -81,6 +86,7 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _outlinedButtonBuilder, assert( text != null || child != null, @@ -98,6 +104,25 @@ class UiButton extends StatelessWidget { this.style, this.iconSize = 20, this.size = UiButtonSize.medium, + this.fullWidth = false, + }) : buttonBuilder = _textButtonBuilder, + assert( + text != null || child != null, + 'Either text or child must be provided', + ); + + /// Creates a ghost button (transparent background). + UiButton.ghost({ + super.key, + this.text, + this.child, + this.onPressed, + this.leadingIcon, + this.trailingIcon, + this.style, + this.iconSize = 20, + this.size = UiButtonSize.medium, + this.fullWidth = false, }) : buttonBuilder = _textButtonBuilder, assert( text != null || child != null, @@ -107,7 +132,18 @@ class UiButton extends StatelessWidget { @override /// Builds the button UI. Widget build(BuildContext context) { - return buttonBuilder(context, onPressed, style, _buildButtonContent()); + final Widget button = buttonBuilder( + context, + onPressed, + style, + _buildButtonContent(), + ); + + if (fullWidth) { + return SizedBox(width: double.infinity, child: button); + } + + return button; } /// Builds the button content with optional leading and trailing icons. @@ -116,27 +152,40 @@ class UiButton extends StatelessWidget { return child!; } - // Single icon or text case + final String buttonText = text ?? ''; + + // Optimization: If no icons, return plain text to avoid Row layout overhead if (leadingIcon == null && trailingIcon == null) { - return Text(text!); + return Text(buttonText, textAlign: TextAlign.center); } - if (leadingIcon != null && text == null && trailingIcon == null) { - return Icon(leadingIcon, size: iconSize); - } - - // Multiple elements case + // Multiple elements case: Use a Row with MainAxisSize.min final List children = []; if (leadingIcon != null) { children.add(Icon(leadingIcon, size: iconSize)); - children.add(const SizedBox(width: UiConstants.space2)); } - children.add(Text(text!)); + if (buttonText.isNotEmpty) { + if (leadingIcon != null) { + children.add(const SizedBox(width: UiConstants.space2)); + } + // Use flexible to ensure text doesn't force infinite width in flex parents + children.add( + Flexible( + child: Text( + buttonText, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } if (trailingIcon != null) { - children.add(const SizedBox(width: UiConstants.space2)); + if (buttonText.isNotEmpty || leadingIcon != null) { + children.add(const SizedBox(width: UiConstants.space2)); + } children.add(Icon(trailingIcon, size: iconSize)); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart index f673e78d..380efaad 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart @@ -97,6 +97,7 @@ class ClientGetStartedPage extends StatelessWidget { .get_started_page .sign_in_button, onPressed: () => Modular.to.pushClientSignIn(), + fullWidth: true, ), const SizedBox(height: UiConstants.space3), @@ -108,6 +109,7 @@ class ClientGetStartedPage extends StatelessWidget { .get_started_page .create_account_button, onPressed: () => Modular.to.pushClientSignUp(), + fullWidth: true, ), ], ), diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart index c1489ae6..9887a9cb 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart @@ -96,6 +96,7 @@ class _ClientSignInFormState extends State { UiButton.primary( text: widget.isLoading ? null : i18n.sign_in_button, onPressed: widget.isLoading ? null : _handleSubmit, + fullWidth: true, child: widget.isLoading ? const SizedBox( height: 24, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index cec480ca..93bb7175 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -20,7 +20,16 @@ class ViewOrderCard extends StatefulWidget { } class _ViewOrderCardState extends State { - bool _expanded = false; + bool _expanded = true; + + void _openEditSheet({required OrderItem order}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) => _OrderEditSheet(order: order), + ); + } /// Returns the semantic color for the given status. Color _getStatusColor({required String status}) { @@ -65,6 +74,13 @@ class _ViewOrderCardState extends State { String _formatDate({required String dateStr}) { try { final DateTime date = DateTime.parse(dateStr); + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime checkDate = DateTime(date.year, date.month, date.day); + + if (checkDate == today) return 'Today'; + if (checkDate == tomorrow) return 'Tomorrow'; return DateFormat('EEE, MMM d').format(date); } catch (_) { return dateStr; @@ -73,7 +89,18 @@ class _ViewOrderCardState extends State { /// Formats the time string for display. String _formatTime({required String timeStr}) { - return timeStr; + if (timeStr.isEmpty) return ''; + try { + final List parts = timeStr.split(':'); + int hour = int.parse(parts[0]); + final int minute = int.parse(parts[1]); + final String ampm = hour >= 12 ? 'PM' : 'AM'; + hour = hour % 12; + if (hour == 0) hour = 12; + return '$hour:${minute.toString().padLeft(2, '0')} $ampm'; + } catch (_) { + return timeStr; + } } @override @@ -205,7 +232,7 @@ class _ViewOrderCardState extends State { const SizedBox(width: UiConstants.space1), GestureDetector( onTap: () { - // TODO: Get directions + // TODO: Handle location }, child: Row( children: [ @@ -236,9 +263,7 @@ class _ViewOrderCardState extends State { icon: UiIcons.edit, color: UiColors.primary, bgColor: UiColors.tagInProgress, - onTap: () { - // TODO: Open edit sheet - }, + onTap: () => _openEditSheet(order: order), ), const SizedBox(width: UiConstants.space2), _buildHeaderIconButton( @@ -319,120 +344,129 @@ class _ViewOrderCardState extends State { const SizedBox(height: UiConstants.space3), - // Coverage Bar + // Coverage Section if (order.status != 'completed') ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - Text( - t.client_view_orders.card.coverage, - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - ), + const Icon( + UiIcons.success, + size: 16, + color: UiColors.textSuccess, ), - const SizedBox(width: UiConstants.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, + const SizedBox(width: 6), + Text( + t.client_view_orders.card.workers_label( + filled: order.filled, + needed: order.workersNeeded, ), - decoration: BoxDecoration( - color: coveragePercent == 100 - ? UiColors.tagSuccess - : UiColors.tagInProgress, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$coveragePercent%', - style: UiTypography.footnote2b.copyWith( - fontSize: 9, - color: coveragePercent == 100 - ? UiColors.textSuccess - : UiColors.primary, - ), + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, ), ), ], ), Text( - t.client_view_orders.card.workers_label( - filled: order.filled, - needed: order.workersNeeded, - ), - style: UiTypography.footnote2m.copyWith( - color: UiColors.textSecondary, + '$coveragePercent%', + style: UiTypography.body2b.copyWith( + color: UiColors.primary, ), ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), ClipRRect( - borderRadius: BorderRadius.circular(2), + borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: coveragePercent / 100, backgroundColor: UiColors.separatorSecondary, - valueColor: AlwaysStoppedAnimation( - coveragePercent == 100 - ? UiColors.textSuccess - : UiColors.primary, + valueColor: const AlwaysStoppedAnimation( + UiColors.primary, ), - minHeight: 4, + minHeight: 8, ), ), + + // Avatar Stack Preview (if not expanded) + if (!_expanded && order.confirmedApps.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space3), + Row( + children: [ + _buildAvatarStack(order.confirmedApps), + if (order.confirmedApps.length > 3) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + '+${order.confirmedApps.length - 3} more', + style: UiTypography.footnote2r.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + ], + ), + ], ], ], ), ), - // Worker Avatars and more details (Expanded section) - if (_expanded) ...[ - const Divider(height: 1, color: UiColors.separatorSecondary), - Padding( + // Assigned Workers (Expanded section) + if (_expanded && order.confirmedApps.isNotEmpty) ...[ + Container( + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + border: Border( + top: BorderSide(color: UiColors.separatorSecondary), + ), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.radiusBase), + ), + ), padding: const EdgeInsets.all(UiConstants.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - t.client_view_orders.card.confirmed_workers, - style: UiTypography.body2b.copyWith( - color: UiColors.textPrimary, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.client_view_orders.card.confirmed_workers, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + ), + ), + UiButton.primary( + text: 'Message All', + leadingIcon: UiIcons.messageCircle, + size: UiButtonSize.small, + // style: ElevatedButton.styleFrom( + // minimumSize: const Size(0, 32), + // maximumSize: const Size(0, 32), + // ), + onPressed: () { + // TODO: Message all workers + }, + ), + ], ), const SizedBox(height: UiConstants.space3), - if (order.confirmedApps.isEmpty) - Text( - t.client_view_orders.card.no_workers, - style: UiTypography.body3r.copyWith( - color: UiColors.textInactive, + ...order.confirmedApps + .take(5) + .map((Map app) => _buildWorkerRow(app)), + if (order.confirmedApps.length > 5) + Center( + child: TextButton( + onPressed: () => setState(() => _expanded = !_expanded), + child: Text( + 'Show ${order.confirmedApps.length - 5} more workers', + style: UiTypography.body3m.copyWith( + color: UiColors.primary, + ), + ), ), - ) - else - Wrap( - spacing: -8, - children: order.confirmedApps - .map( - (Map app) => Tooltip( - message: app['worker_name'] as String, - child: CircleAvatar( - radius: 14, - backgroundColor: UiColors.white, - child: CircleAvatar( - radius: 12, - backgroundColor: UiColors.bgSecondary, - child: Text( - (app['worker_name'] as String).substring( - 0, - 1, - ), - style: UiTypography.footnote2b, - ), - ), - ), - ), - ) - .toList(), ), ], ), @@ -443,6 +477,163 @@ class _ViewOrderCardState extends State { ); } + /// Builds a stacked avatar UI for a list of applications. + Widget _buildAvatarStack(List> apps) { + const double size = 28.0; + const double overlap = 20.0; + final int count = apps.length > 3 ? 3 : apps.length; + + return SizedBox( + height: size, + width: size + (count - 1) * overlap, + child: Stack( + children: [ + for (int i = 0; i < count; i++) + Positioned( + left: i * overlap, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: UiColors.white, width: 2), + color: UiColors.tagInProgress, + ), + child: Center( + child: Text( + (apps[i]['worker_name'] as String)[0], + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// Builds a detailed row for a worker. + Widget _buildWorkerRow(Map app) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // Avatar + CircleAvatar( + radius: 18, + backgroundColor: UiColors.tagInProgress, + child: Text( + (app['worker_name'] as String)[0], + style: UiTypography.body2b.copyWith( + color: UiColors.primary, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app['worker_name'] as String, + style: UiTypography.body2m.copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + border: Border.all( + color: UiColors.separatorPrimary, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '⭐ 4.8', + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + ), + ), + ), + if (app['check_in_time'] != null) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: UiColors.tagSuccess, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Checked In', + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSuccess, + ), + ), + ), + ], + ], + ), + ], + ), + ], + ), + Row( + children: [ + _buildActionIconButton(icon: UiIcons.phone, onTap: () {}), + const SizedBox(width: 8), + _buildActionIconButton( + icon: UiIcons.messageCircle, + onTap: () {}, + ), + const SizedBox(width: 8), + const Icon( + UiIcons.success, + size: 20, + color: UiColors.textSuccess, + ), + ], + ), + ], + ), + ), + ); + } + + /// Specialized action button for worker rows. + Widget _buildActionIconButton({ + required IconData icon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: const BoxDecoration(color: UiColors.transparent), + child: Icon(icon, size: 16, color: UiColors.primary), + ), + ); + } + /// Builds a small icon button used in row headers. Widget _buildHeaderIconButton({ required IconData icon, @@ -523,3 +714,191 @@ class _ViewOrderCardState extends State { ); } } + +/// A bottom sheet for editing an existing order. +class _OrderEditSheet extends StatefulWidget { + const _OrderEditSheet({required this.order}); + + final OrderItem order; + + @override + State<_OrderEditSheet> createState() => _OrderEditSheetState(); +} + +class _OrderEditSheetState extends State<_OrderEditSheet> { + late TextEditingController _titleController; + late TextEditingController _dateController; + late TextEditingController _locationController; + late TextEditingController _workersNeededController; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.order.title); + _dateController = TextEditingController(text: widget.order.date); + _locationController = TextEditingController( + text: widget.order.locationAddress, + ); + _workersNeededController = TextEditingController( + text: widget.order.workersNeeded.toString(), + ); + } + + @override + void dispose() { + _titleController.dispose(); + _dateController.dispose(); + _locationController.dispose(); + _workersNeededController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.radiusBase * 2), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Order', + style: UiTypography.title1m.copyWith( + color: UiColors.white, + ), + ), + Text( + 'Refine your staffing needs', + style: UiTypography.body3r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ], + ), + ), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Position Title'), + UiTextField( + controller: _titleController, + hintText: 'e.g. Server, Bartender', + prefixIcon: UiIcons.briefcase, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Date'), + UiTextField( + controller: _dateController, + hintText: 'Select Date', + prefixIcon: UiIcons.calendar, + readOnly: true, + onTap: () { + // TODO: Show date picker + }, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Location'), + UiTextField( + controller: _locationController, + hintText: 'Business address', + prefixIcon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space4), + + _buildSectionLabel('Workers Needed'), + Row( + children: [ + Expanded( + child: UiTextField( + controller: _workersNeededController, + hintText: 'Quantity', + prefixIcon: UiIcons.users, + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + UiButton.primary( + text: 'Save Changes', + fullWidth: true, + onPressed: () { + // TODO: Implement save logic + Navigator.pop(context); + }, + ), + const SizedBox(height: UiConstants.space3), + UiButton.ghost( + text: 'Cancel', + fullWidth: true, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSectionLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label.toUpperCase(), + style: UiTypography.titleUppercase4m.copyWith( + color: UiColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + ); + } +}