From a53dddf2e656cac07d8ad3571b0926246edbab4a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 20:37:14 -0500 Subject: [PATCH 01/38] feat: Implement voice-to-text transcription for rapid order creation and refactor RapidOrderBloc state management. --- apps/mobile/apps/client/lib/main.dart | 5 +- .../lib/src/l10n/en.i18n.json | 1 + .../lib/src/l10n/es.i18n.json | 1 + .../lib/src/create_order_module.dart | 6 +- .../blocs/rapid_order/rapid_order_bloc.dart | 114 +++++++---- .../blocs/rapid_order/rapid_order_state.dart | 68 +++---- .../widgets/rapid_order/rapid_order_view.dart | 192 +++++++----------- 7 files changed, 188 insertions(+), 199 deletions(-) diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 2917828c..301ade92 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -51,7 +51,10 @@ void main() async { /// The main application module for the Client app. class AppModule extends Module { @override - List get imports => [core_localization.LocalizationModule()]; + List get imports => [ + core_localization.LocalizationModule(), + CoreModule(), + ]; @override void routes(RouteManager r) { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 82b9cebe..2ecac88b 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -349,6 +349,7 @@ "listening": "Listening...", "send": "Send Message", "sending": "Sending...", + "transcribing": "Transcribing...", "success_title": "Request Sent!", "success_message": "We're finding available workers for you right now. You'll be notified as they accept.", "back_to_orders": "Back to Orders" diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index dd5a0c73..00d8d979 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -349,6 +349,7 @@ "listening": "Escuchando...", "send": "Enviar Mensaje", "sending": "Enviando...", + "transcribing": "Transcribiendo...", "success_title": "\u00a1Solicitud Enviada!", "success_message": "Estamos encontrando trabajadores disponibles para ti ahora mismo. Te notificaremos cuando acepten.", "back_to_orders": "Volver a \u00d3rdenes" diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index db8d49ed..98f02894 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -26,13 +26,13 @@ import 'presentation/pages/recurring_order_page.dart'; /// presentation layer BLoCs. class ClientCreateOrderModule extends Module { @override - List get imports => [DataConnectModule()]; + List get imports => [DataConnectModule(), CoreModule()]; @override void binds(Injector i) { // Repositories i.addLazySingleton( - (Injector i) => ClientCreateOrderRepositoryImpl( + () => ClientCreateOrderRepositoryImpl( service: i.get(), rapidOrderService: i.get(), ), @@ -49,7 +49,7 @@ class ClientCreateOrderModule extends Module { // BLoCs i.add( - (Injector i) => RapidOrderBloc( + () => RapidOrderBloc( i.get(), i.get(), i.get(), diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart index 3b957100..f9cc14e6 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_bloc.dart @@ -15,7 +15,7 @@ class RapidOrderBloc extends Bloc this._parseRapidOrderUseCase, this._audioRecorderService, ) : super( - const RapidOrderInitial( + const RapidOrderState( examples: [ '"We had a call out. Need 2 cooks ASAP"', '"Need 5 bartenders ASAP until 5am"', @@ -36,33 +36,76 @@ class RapidOrderBloc extends Bloc RapidOrderMessageChanged event, Emitter emit, ) { - if (state is RapidOrderInitial) { - emit((state as RapidOrderInitial).copyWith(message: event.message)); - } + emit( + state.copyWith(message: event.message, status: RapidOrderStatus.initial), + ); } Future _onVoiceToggled( RapidOrderVoiceToggled event, Emitter emit, ) async { - if (state is RapidOrderInitial) { - final RapidOrderInitial currentState = state as RapidOrderInitial; - final bool newListeningState = !currentState.isListening; - - emit(currentState.copyWith(isListening: newListeningState)); - - // Simulate voice recognition - if (newListeningState) { - await Future.delayed(const Duration(seconds: 2)); - if (state is RapidOrderInitial) { + if (!state.isListening) { + // Start Recording + await handleError( + emit: emit.call, + action: () async { + await _audioRecorderService.startRecording(); emit( - (state as RapidOrderInitial).copyWith( - message: 'Need 2 servers for a banquet right now.', + state.copyWith(isListening: true, status: RapidOrderStatus.initial), + ); + }, + onError: (String errorKey) => + state.copyWith(status: RapidOrderStatus.failure, error: errorKey), + ); + } else { + // Stop Recording and Transcribe + await handleError( + emit: emit.call, + action: () async { + // 1. Stop recording + final String? audioPath = await _audioRecorderService.stopRecording(); + + if (audioPath == null) { + emit( + state.copyWith( + isListening: false, + status: RapidOrderStatus.initial, + ), + ); + return; + } + + // 2. Transcribe + emit( + state.copyWith( isListening: false, + isTranscribing: true, + status: RapidOrderStatus.initial, ), ); - } - } + + final String transcription = await _transcribeRapidOrderUseCase( + audioPath, + ); + + // 3. Update message + emit( + state.copyWith( + message: transcription, + isListening: false, + isTranscribing: false, + status: RapidOrderStatus.initial, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: RapidOrderStatus.failure, + error: errorKey, + isListening: false, + isTranscribing: false, + ), + ); } } @@ -70,30 +113,29 @@ class RapidOrderBloc extends Bloc RapidOrderSubmitted event, Emitter emit, ) async { - final RapidOrderState currentState = state; - if (currentState is RapidOrderInitial) { - final String message = currentState.message; - emit(const RapidOrderSubmitting()); + final String message = state.message; + emit(state.copyWith(status: RapidOrderStatus.submitting)); - await handleError( - emit: emit.call, - action: () async { - final OneTimeOrder order = await _parseRapidOrderUseCase(message); - emit(RapidOrderParsed(order)); - }, - onError: (String errorKey) => RapidOrderFailure(errorKey), - ); - } + await handleError( + emit: emit.call, + action: () async { + final OneTimeOrder order = await _parseRapidOrderUseCase(message); + emit( + state.copyWith(status: RapidOrderStatus.parsed, parsedOrder: order), + ); + }, + onError: (String errorKey) => + state.copyWith(status: RapidOrderStatus.failure, error: errorKey), + ); } void _onExampleSelected( RapidOrderExampleSelected event, Emitter emit, ) { - if (state is RapidOrderInitial) { - final String cleanedExample = event.example.replaceAll('"', ''); - emit((state as RapidOrderInitial).copyWith(message: cleanedExample)); - } + final String cleanedExample = event.example.replaceAll('"', ''); + emit( + state.copyWith(message: cleanedExample, status: RapidOrderStatus.initial), + ); } } - diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart index 88396c09..af3abd99 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/rapid_order/rapid_order_state.dart @@ -1,59 +1,55 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; -abstract class RapidOrderState extends Equatable { - const RapidOrderState(); +enum RapidOrderStatus { initial, submitting, parsed, failure } - @override - List get props => []; -} - -class RapidOrderInitial extends RapidOrderState { - const RapidOrderInitial({ +class RapidOrderState extends Equatable { + const RapidOrderState({ + this.status = RapidOrderStatus.initial, this.message = '', this.isListening = false, - required this.examples, + this.isTranscribing = false, + this.examples = const [], + this.error, + this.parsedOrder, }); + + final RapidOrderStatus status; final String message; final bool isListening; + final bool isTranscribing; final List examples; + final String? error; + final OneTimeOrder? parsedOrder; @override - List get props => [message, isListening, examples]; + List get props => [ + status, + message, + isListening, + isTranscribing, + examples, + error, + parsedOrder, + ]; - RapidOrderInitial copyWith({ + RapidOrderState copyWith({ + RapidOrderStatus? status, String? message, bool? isListening, + bool? isTranscribing, List? examples, + String? error, + OneTimeOrder? parsedOrder, }) { - return RapidOrderInitial( + return RapidOrderState( + status: status ?? this.status, message: message ?? this.message, isListening: isListening ?? this.isListening, + isTranscribing: isTranscribing ?? this.isTranscribing, examples: examples ?? this.examples, + error: error ?? this.error, + parsedOrder: parsedOrder ?? this.parsedOrder, ); } } - -class RapidOrderSubmitting extends RapidOrderState { - const RapidOrderSubmitting(); -} - -class RapidOrderSuccess extends RapidOrderState { - const RapidOrderSuccess(); -} - -class RapidOrderFailure extends RapidOrderState { - const RapidOrderFailure(this.error); - final String error; - - @override - List get props => [error]; -} - -class RapidOrderParsed extends RapidOrderState { - const RapidOrderParsed(this.order); - final OneTimeOrder order; - - @override - List get props => [order]; -} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index f306e312..982ba0d2 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -10,7 +10,6 @@ import '../../blocs/rapid_order/rapid_order_event.dart'; import '../../blocs/rapid_order/rapid_order_state.dart'; import 'rapid_order_example_card.dart'; import 'rapid_order_header.dart'; -import 'rapid_order_success_view.dart'; /// The main content of the Rapid Order page. class RapidOrderView extends StatelessWidget { @@ -19,23 +18,7 @@ class RapidOrderView extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderRapidEn labels = - t.client_create_order.rapid; - - return BlocBuilder( - builder: (BuildContext context, RapidOrderState state) { - if (state is RapidOrderSuccess) { - return RapidOrderSuccessView( - title: labels.success_title, - message: labels.success_message, - buttonLabel: labels.back_to_orders, - onDone: () => Modular.to.toClientOrders(), - ); - } - - return const _RapidOrderForm(); - }, - ); + return const _RapidOrderForm(); } } @@ -65,24 +48,26 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { return BlocListener( listener: (BuildContext context, RapidOrderState state) { - if (state is RapidOrderInitial) { + if (state.status == RapidOrderStatus.initial) { if (_messageController.text != state.message) { _messageController.text = state.message; _messageController.selection = TextSelection.fromPosition( TextPosition(offset: _messageController.text.length), ); } - } else if (state is RapidOrderParsed) { + } else if (state.status == RapidOrderStatus.parsed && + state.parsedOrder != null) { Modular.to.toCreateOrderOneTime( arguments: { - 'order': state.order, + 'order': state.parsedOrder, 'isRapidDraft': true, }, ); - } else if (state is RapidOrderFailure) { + } else if (state.status == RapidOrderStatus.failure && + state.error != null) { UiSnackbar.show( context, - message: translateErrorKey(state.error), + message: translateErrorKey(state.error!), type: UiSnackbarType.error, ); } @@ -100,63 +85,25 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { // Content Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - labels.tell_us, - style: UiTypography.headline3m.textPrimary, - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.destructive, - borderRadius: UiConstants.radiusSm, - ), - child: Text( - labels.urgent_badge, - style: UiTypography.footnote2b.copyWith( - color: UiColors.white, - ), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), + child: BlocBuilder( + builder: (BuildContext context, RapidOrderState state) { + final bool isSubmitting = + state.status == RapidOrderStatus.submitting; - // Main Card - Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: BlocBuilder( - builder: (BuildContext context, RapidOrderState state) { - final RapidOrderInitial? initialState = - state is RapidOrderInitial ? state : null; - final bool isSubmitting = - state is RapidOrderSubmitting; - - return Column( + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( children: [ // Icon const _AnimatedZapIcon(), const SizedBox(height: UiConstants.space4), Text( labels.need_staff, - style: UiTypography.headline2m.textPrimary, + style: UiTypography.headline3b.textPrimary, ), - const SizedBox(height: UiConstants.space2), Text( labels.type_or_speak, textAlign: TextAlign.center, @@ -165,31 +112,30 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { const SizedBox(height: UiConstants.space6), // Examples - if (initialState != null) - ...initialState.examples.asMap().entries.map(( - MapEntry entry, - ) { - final int index = entry.key; - final String example = entry.value; - final bool isHighlighted = index == 0; + ...state.examples.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final String example = entry.value; + final bool isHighlighted = index == 0; - return Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space2, - ), - child: RapidOrderExampleCard( - example: example, - isHighlighted: isHighlighted, - label: labels.example, - onTap: () => - BlocProvider.of( - context, - ).add( - RapidOrderExampleSelected(example), - ), - ), - ); - }), + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space2, + ), + child: RapidOrderExampleCard( + example: example, + isHighlighted: isHighlighted, + label: labels.example, + onTap: () => + BlocProvider.of( + context, + ).add( + RapidOrderExampleSelected(example), + ), + ), + ); + }), const SizedBox(height: UiConstants.space4), // Input @@ -203,24 +149,23 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { }, hintText: labels.hint, ), - const SizedBox(height: UiConstants.space4), - - // Actions - _RapidOrderActions( - labels: labels, - isSubmitting: isSubmitting, - isListening: initialState?.isListening ?? false, - isMessageEmpty: - initialState != null && - initialState.message.trim().isEmpty, - ), ], - ); - }, + ), + ), ), - ), - ], - ), + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: _RapidOrderActions( + labels: labels, + isSubmitting: isSubmitting, + isListening: state.isListening, + isTranscribing: state.isTranscribing, + isMessageEmpty: state.message.trim().isEmpty, + ), + ), + ], + ); + }, ), ), ], @@ -248,13 +193,6 @@ class _AnimatedZapIcon extends StatelessWidget { end: Alignment.bottomRight, ), borderRadius: UiConstants.radiusLg, - boxShadow: [ - BoxShadow( - color: UiColors.destructive.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], ), child: const Icon(UiIcons.zap, color: UiColors.white, size: 32), ); @@ -266,11 +204,13 @@ class _RapidOrderActions extends StatelessWidget { required this.labels, required this.isSubmitting, required this.isListening, + required this.isTranscribing, required this.isMessageEmpty, }); final TranslationsClientCreateOrderRapidEn labels; final bool isSubmitting; final bool isListening; + final bool isTranscribing; final bool isMessageEmpty; @override @@ -279,11 +219,17 @@ class _RapidOrderActions extends StatelessWidget { children: [ Expanded( child: UiButton.secondary( - text: isListening ? labels.listening : labels.speak, + text: isTranscribing + ? labels.transcribing + : isListening + ? labels.listening + : labels.speak, leadingIcon: UiIcons.microphone, - onPressed: () => BlocProvider.of( - context, - ).add(const RapidOrderVoiceToggled()), + onPressed: isTranscribing + ? null + : () => BlocProvider.of( + context, + ).add(const RapidOrderVoiceToggled()), style: OutlinedButton.styleFrom( backgroundColor: isListening ? UiColors.destructive.withValues(alpha: 0.05) From 95175536a0f4ddf4fa9d5da6e9747c6b4d2d5fc5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 21:02:34 -0500 Subject: [PATCH 02/38] feat: Implement audio file upload for rapid order transcription and refine the rapid order verification page titles. --- .../lib/src/create_order_module.dart | 1 + .../client_create_order_repository_impl.dart | 17 +++++++++++++++-- .../presentation/pages/one_time_order_page.dart | 3 ++- .../one_time_order/one_time_order_view.dart | 6 ++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index 98f02894..b17c6513 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -35,6 +35,7 @@ class ClientCreateOrderModule extends Module { () => ClientCreateOrderRepositoryImpl( service: i.get(), rapidOrderService: i.get(), + fileUploadService: i.get(), ), ); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 6b58aa75..215e054f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -17,11 +17,14 @@ class ClientCreateOrderRepositoryImpl ClientCreateOrderRepositoryImpl({ required dc.DataConnectService service, required RapidOrderService rapidOrderService, + required FileUploadService fileUploadService, }) : _service = service, - _rapidOrderService = rapidOrderService; + _rapidOrderService = rapidOrderService, + _fileUploadService = fileUploadService; final dc.DataConnectService _service; final RapidOrderService _rapidOrderService; + final FileUploadService _fileUploadService; @override Future createOneTimeOrder(domain.OneTimeOrder order) async { @@ -412,8 +415,18 @@ class ClientCreateOrderRepositoryImpl @override Future transcribeRapidOrder(String audioPath) async { + // 1. Upload the audio file first + final String fileName = audioPath.split('/').last; + final FileUploadResponse uploadResponse = await _fileUploadService + .uploadFile( + filePath: audioPath, + fileName: fileName, + category: 'rapid-order-audio', + ); + + // 2. Transcribe using the remote URI final RapidOrderTranscriptionResponse response = await _rapidOrderService - .transcribeAudio(audioFileUri: audioPath); + .transcribeAudio(audioFileUri: uploadResponse.fileUri); return response.transcript; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 11d8e1d7..1c83311f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -53,7 +53,8 @@ class OneTimeOrderPage extends StatelessWidget { : null, hubManagers: state.managers.map(_mapManager).toList(), isValid: state.isValid, - title: state.isRapidDraft ? 'Rapid Order : Verify the order' : null, + title: state.isRapidDraft ? 'Rapid Order' : null, + subtitle: state.isRapidDraft ? 'Verify the order details' : null, onEventNameChanged: (String val) => bloc.add(OneTimeOrderEventNameChanged(val)), onVendorChanged: (Vendor val) => diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index bb80fcc4..97d0bb68 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -39,6 +39,7 @@ class OneTimeOrderView extends StatelessWidget { required this.onDone, required this.onBack, this.title, + this.subtitle, super.key, }); @@ -56,6 +57,7 @@ class OneTimeOrderView extends StatelessWidget { final OrderManagerUiModel? selectedHubManager; final bool isValid; final String? title; + final String? subtitle; final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; @@ -102,7 +104,7 @@ class OneTimeOrderView extends StatelessWidget { children: [ OneTimeOrderHeader( title: title ?? labels.title, - subtitle: labels.subtitle, + subtitle: subtitle ?? labels.subtitle, onBack: onBack, ), Expanded( @@ -140,7 +142,7 @@ class OneTimeOrderView extends StatelessWidget { children: [ OneTimeOrderHeader( title: title ?? labels.title, - subtitle: labels.subtitle, + subtitle: subtitle ?? labels.subtitle, onBack: onBack, ), Expanded( From bba4054143a16270438ea7bfd07aa3a0cac42ac4 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 21:32:12 -0500 Subject: [PATCH 03/38] feat: Implement hub and role matching for order creation and remove payment, savings, and export sections from the billing page. --- .../src/presentation/pages/billing_page.dart | 95 ++--------- .../widgets/invoice_history_section.dart | 36 +---- .../widgets/pending_invoices_section.dart | 9 +- .../client_create_order_repository_impl.dart | 150 +++++++++++++++++- 4 files changed, 159 insertions(+), 131 deletions(-) diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 01d44775..20b2f0ef 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -9,7 +9,6 @@ import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../blocs/billing_state.dart'; import '../widgets/invoice_history_section.dart'; -import '../widgets/payment_method_card.dart'; import '../widgets/pending_invoices_section.dart'; import '../widgets/spending_breakdown_card.dart'; @@ -106,14 +105,14 @@ class _BillingViewState extends State { ), title: Text( t.client_billing.title, - style: UiTypography.headline3b.copyWith(color: UiColors.white), + style: UiTypography.headline3b.copyWith( + color: UiColors.white, + ), ), centerTitle: false, flexibleSpace: FlexibleSpaceBar( background: Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space8, - ), + padding: const EdgeInsets.only(bottom: UiConstants.space8), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -224,90 +223,16 @@ class _BillingViewState extends State { if (state.pendingInvoices.isNotEmpty) ...[ PendingInvoicesSection(invoices: state.pendingInvoices), ], - const PaymentMethodCard(), + // const PaymentMethodCard(), const SpendingBreakdownCard(), - _buildSavingsCard(state.savings), if (state.invoiceHistory.isNotEmpty) InvoiceHistorySection(invoices: state.invoiceHistory), - - _buildExportButton(), - const SizedBox(height: UiConstants.space12), + const SizedBox(height: UiConstants.space16), ], ), ); } - Widget _buildSavingsCard(double amount) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.accent.withOpacity(0.5)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(UiConstants.space2), - decoration: BoxDecoration( - color: UiColors.accent, - borderRadius: UiConstants.radiusMd, - ), - child: const Icon(UiIcons.trendingDown, size: 18, color: UiColors.accentForeground), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_billing.rate_optimization_title, - style: UiTypography.body2b.textPrimary, - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - style: UiTypography.footnote2r.textSecondary, - children: [ - TextSpan(text: t.client_billing.rate_optimization_save), - TextSpan( - text: t.client_billing.rate_optimization_amount(amount: amount.toStringAsFixed(0)), - style: UiTypography.footnote2b.textPrimary, - ), - TextSpan(text: t.client_billing.rate_optimization_shifts), - ], - ), - ), - const SizedBox(height: UiConstants.space3), - SizedBox( - height: 32, - child: UiButton.primary( - text: t.client_billing.view_details, - onPressed: () {}, - size: UiButtonSize.small, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildExportButton() { - return SizedBox( - width: double.infinity, - child: UiButton.secondary( - text: t.client_billing.export_button, - leadingIcon: UiIcons.download, - onPressed: () {}, - size: UiButtonSize.large, - ), - ); - } - Widget _buildEmptyState(BuildContext context) { return Center( child: Column( @@ -361,11 +286,15 @@ class _InvoicesReadyBanner extends StatelessWidget { children: [ Text( t.client_billing.invoices_ready_title, - style: UiTypography.body1b.copyWith(color: UiColors.success), + style: UiTypography.body1b.copyWith( + color: UiColors.success, + ), ), Text( t.client_billing.invoices_ready_subtitle, - style: UiTypography.footnote2r.copyWith(color: UiColors.success), + style: UiTypography.footnote2r.copyWith( + color: UiColors.success, + ), ), ], ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index 6102aa4c..55096618 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -14,33 +14,11 @@ class InvoiceHistorySection extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_billing.invoice_history, - style: UiTypography.title2b.textPrimary, - ), - TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Row( - children: [ - Text( - t.client_billing.view_all, - style: UiTypography.body2b.copyWith(color: UiColors.primary), - ), - const SizedBox(width: 4), - const Icon(UiIcons.chevronRight, size: 16, color: UiColors.primary), - ], - ), - ), - ], + Text( + t.client_billing.invoice_history, + style: UiTypography.title2b.textPrimary, ), const SizedBox(height: UiConstants.space3), Container( @@ -129,12 +107,6 @@ class _InvoiceItem extends StatelessWidget { _StatusBadge(status: invoice.status), ], ), - const SizedBox(width: UiConstants.space4), - Icon( - UiIcons.download, - size: 20, - color: UiColors.iconSecondary.withOpacity(0.3), - ), ], ), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 2905f6b8..9a36922f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -21,18 +21,11 @@ class PendingInvoicesSection extends StatelessWidget { return GestureDetector( onTap: () => Modular.to.toAwaitingApproval(), child: Container( - padding: const EdgeInsets.all(UiConstants.space5), + padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], ), child: Row( children: [ diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 215e054f..2891e30a 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -382,29 +382,82 @@ class ClientCreateOrderRepositoryImpl ); final RapidOrderParsedData data = response.parsed; + // Fetch Business ID + final String businessId = await _service.getBusinessId(); + + // 1. Hub Matching + final OperationResult< + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > + hubResult = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + final List hubs = hubResult.data.teamHubs; + + final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub( + hubs, + data.locationHint, + ); + + // 2. Roles Matching + // We fetch vendors to get the first one as a context for role matching. + final OperationResult vendorResult = + await _service.connector.listVendors().execute(); + final List vendors = vendorResult.data.vendors; + + String? selectedVendorId; + List availableRoles = + []; + + if (vendors.isNotEmpty) { + selectedVendorId = vendors.first.id; + final OperationResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + roleResult = await _service.connector + .listRolesByVendorId(vendorId: selectedVendorId) + .execute(); + availableRoles = roleResult.data.roles; + } + final DateTime startAt = DateTime.tryParse(data.startAt ?? '') ?? DateTime.now(); final DateTime endAt = DateTime.tryParse(data.endAt ?? '') ?? startAt.add(const Duration(hours: 8)); - final String startTimeStr = DateFormat('hh:mm a').format(startAt); - final String endTimeStr = DateFormat('hh:mm a').format(endAt); + final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal()); + final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal()); return domain.OneTimeOrder( date: startAt, - location: data.locationHint ?? '', + location: bestHub?.hubName ?? data.locationHint ?? '', eventName: data.notes ?? '', - hub: data.locationHint != null + vendorId: selectedVendorId, + hub: bestHub != null ? domain.OneTimeOrderHubDetails( - id: '', - name: data.locationHint!, - address: '', + id: bestHub.id, + name: bestHub.hubName, + address: bestHub.address, + placeId: bestHub.placeId, + latitude: bestHub.latitude ?? 0, + longitude: bestHub.longitude ?? 0, + city: bestHub.city, + state: bestHub.state, + street: bestHub.street, + country: bestHub.country, + zipCode: bestHub.zipCode, ) : null, positions: data.positions.map((RapidOrderPosition p) { + final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole( + availableRoles, + p.role, + ); return domain.OneTimeOrderPosition( - role: p.role, + role: matchedRole?.id ?? p.role, count: p.count, startTime: startTimeStr, endTime: endTimeStr, @@ -656,4 +709,85 @@ class ClientCreateOrderRepositoryImpl } return domain.OrderType.oneTime; } + + dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub( + List hubs, + String? hint, + ) { + if (hint == null || hint.isEmpty || hubs.isEmpty) return null; + final String normalizedHint = hint.toLowerCase(); + + dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch; + double highestScore = -1; + + for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { + final String name = hub.hubName.toLowerCase(); + final String address = hub.address.toLowerCase(); + + double score = 0; + if (name == normalizedHint || address == normalizedHint) { + score = 100; + } else if (name.contains(normalizedHint) || + address.contains(normalizedHint)) { + score = 80; + } else if (normalizedHint.contains(name) || + normalizedHint.contains(address)) { + score = 60; + } else { + final List hintWords = normalizedHint.split(RegExp(r'\s+')); + final List hubWords = ('$name $address').split(RegExp(r'\s+')); + int overlap = 0; + for (final String word in hintWords) { + if (word.length > 2 && hubWords.contains(word)) overlap++; + } + score = overlap * 10.0; + } + + if (score > highestScore) { + highestScore = score; + bestMatch = hub; + } + } + + return (highestScore >= 10) ? bestMatch : null; + } + + dc.ListRolesByVendorIdRoles? _findBestRole( + List roles, + String? hint, + ) { + if (hint == null || hint.isEmpty || roles.isEmpty) return null; + final String normalizedHint = hint.toLowerCase(); + + dc.ListRolesByVendorIdRoles? bestMatch; + double highestScore = -1; + + for (final dc.ListRolesByVendorIdRoles role in roles) { + final String name = role.name.toLowerCase(); + + double score = 0; + if (name == normalizedHint) { + score = 100; + } else if (name.contains(normalizedHint)) { + score = 80; + } else if (normalizedHint.contains(name)) { + score = 60; + } else { + final List hintWords = normalizedHint.split(RegExp(r'\s+')); + final List roleWords = name.split(RegExp(r'\s+')); + int overlap = 0; + for (final String word in hintWords) { + if (word.length > 2 && roleWords.contains(word)) overlap++; + } + score = overlap * 10.0; + } + + if (score > highestScore) { + highestScore = score; + bestMatch = role; + } + } + + return (highestScore >= 10) ? bestMatch : null; + } } From a65181251dc146be29bd45021610f3b43992660f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 21:43:50 -0500 Subject: [PATCH 04/38] feat: Introduce `showBottomBar` state to conditionally hide the bottom navigation bar based on specific routes. --- .../src/presentation/blocs/client_main_cubit.dart | 12 +++++++++--- .../src/presentation/blocs/client_main_state.dart | 11 ++++++++--- .../lib/src/presentation/pages/client_main_page.dart | 4 +++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart index 9729a66d..3822645c 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart @@ -14,7 +14,6 @@ class ClientMainCubit extends Cubit implements Disposable { int newIndex = state.currentIndex; // Detect which tab is active based on the route path - // Using contains() to handle child routes and trailing slashes if (path.contains(ClientPaths.coverage)) { newIndex = 0; } else if (path.contains(ClientPaths.billing)) { @@ -27,8 +26,15 @@ class ClientMainCubit extends Cubit implements Disposable { newIndex = 4; } - if (newIndex != state.currentIndex) { - emit(state.copyWith(currentIndex: newIndex)); + final bool showBottomBar = + !path.contains(ClientPaths.completionReview) && + !path.contains(ClientPaths.awaitingApproval); + + if (newIndex != state.currentIndex || + showBottomBar != state.showBottomBar) { + emit( + state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar), + ); } } diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart index f2573616..78650cae 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart @@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart'; class ClientMainState extends Equatable { const ClientMainState({ this.currentIndex = 2, // Default to Home + this.showBottomBar = true, }); final int currentIndex; + final bool showBottomBar; - ClientMainState copyWith({int? currentIndex}) { - return ClientMainState(currentIndex: currentIndex ?? this.currentIndex); + ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) { + return ClientMainState( + currentIndex: currentIndex ?? this.currentIndex, + showBottomBar: showBottomBar ?? this.showBottomBar, + ); } @override - List get props => [currentIndex]; + List get props => [currentIndex, showBottomBar]; } diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/pages/client_main_page.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/pages/client_main_page.dart index b01d1c9b..9f6bde79 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/pages/client_main_page.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/pages/client_main_page.dart @@ -24,13 +24,15 @@ class ClientMainPage extends StatelessWidget { body: const RouterOutlet(), bottomNavigationBar: BlocBuilder( builder: (BuildContext context, ClientMainState state) { + if (!state.showBottomBar) return const SizedBox.shrink(); + return ClientMainBottomBar( currentIndex: state.currentIndex, onTap: (int index) { BlocProvider.of(context).navigateToTab(index); }, ); - }, + }, ), ), ); From 1ed6d27ca71cdfdee77becc5d10b696b337ded11 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 21:45:43 -0500 Subject: [PATCH 05/38] refactor: Centralize bottom bar visibility paths into a list and simplify the conditional check. --- .../lib/src/presentation/blocs/client_main_cubit.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart index 3822645c..1b6683fd 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart @@ -9,6 +9,11 @@ class ClientMainCubit extends Cubit implements Disposable { _onRouteChanged(); } + static const List _hideBottomBarPaths = [ + ClientPaths.completionReview, + ClientPaths.awaitingApproval, + ]; + void _onRouteChanged() { final String path = Modular.to.path; int newIndex = state.currentIndex; @@ -26,9 +31,7 @@ class ClientMainCubit extends Cubit implements Disposable { newIndex = 4; } - final bool showBottomBar = - !path.contains(ClientPaths.completionReview) && - !path.contains(ClientPaths.awaitingApproval); + final bool showBottomBar = !_hideBottomBarPaths.any(path.contains); if (newIndex != state.currentIndex || showBottomBar != state.showBottomBar) { From 752f60405efbca9c146da53f91789e8d87577331 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 12:11:46 -0500 Subject: [PATCH 06/38] feat: Enable the rapid order type, refactor the invoice ready page to use `UiAppBar`, and adjust rapid action widget colors. --- .../pages/completion_review_page.dart | 229 ++++++++++-------- .../pages/invoice_ready_page.dart | 56 +++-- .../presentation/widgets/actions_widget.dart | 2 +- .../utils/constants/order_types.dart | 12 +- 4 files changed, 161 insertions(+), 138 deletions(-) diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 99bd872a..3856857f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -1,20 +1,20 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; + import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../models/billing_invoice_model.dart'; class ShiftCompletionReviewPage extends StatefulWidget { const ShiftCompletionReviewPage({this.invoice, super.key}); - + final BillingInvoice? invoice; @override - State createState() => _ShiftCompletionReviewPageState(); + State createState() => + _ShiftCompletionReviewPageState(); } class _ShiftCompletionReviewPageState extends State { @@ -31,99 +31,62 @@ class _ShiftCompletionReviewPageState extends State { @override Widget build(BuildContext context) { - final List filteredWorkers = invoice.workers.where((BillingWorkerRecord w) { + final List filteredWorkers = invoice.workers.where(( + BillingWorkerRecord w, + ) { if (searchQuery.isEmpty) return true; return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) || - w.roleName.toLowerCase().contains(searchQuery.toLowerCase()); + w.roleName.toLowerCase().contains(searchQuery.toLowerCase()); }).toList(); return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + appBar: UiAppBar( + title: invoice.title, + subtitle: invoice.clientName, + showBackButton: true, + ), body: SafeArea( - child: Column( - children: [ - _buildHeader(context), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: UiConstants.space4), - _buildInvoiceInfoCard(), - const SizedBox(height: UiConstants.space4), - _buildAmountCard(), - const SizedBox(height: UiConstants.space6), - _buildWorkersHeader(), - const SizedBox(height: UiConstants.space4), - _buildSearchAndTabs(), - const SizedBox(height: UiConstants.space4), - ...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)), - const SizedBox(height: UiConstants.space6), - _buildActionButtons(context), - const SizedBox(height: UiConstants.space4), - _buildDownloadLink(), - const SizedBox(height: UiConstants.space8), - ], + child: Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: UiConstants.space4), + _buildInvoiceInfoCard(), + const SizedBox(height: UiConstants.space4), + _buildAmountCard(), + const SizedBox(height: UiConstants.space6), + _buildWorkersHeader(), + const SizedBox(height: UiConstants.space4), + _buildSearchAndTabs(), + const SizedBox(height: UiConstants.space4), + ...filteredWorkers.map( + (BillingWorkerRecord worker) => _buildWorkerCard(worker), ), - ), + const SizedBox(height: UiConstants.space6), + _buildActionButtons(context), + const SizedBox(height: UiConstants.space4), + _buildDownloadLink(), + const SizedBox(height: UiConstants.space8), + ], ), - ], + ), ), ), ); } - Widget _buildHeader(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4), - decoration: const BoxDecoration( - color: Colors.white, - border: Border(bottom: BorderSide(color: UiColors.border)), - ), - child: Column( - children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: UiColors.border, - borderRadius: UiConstants.radiusFull, - ), - ), - const SizedBox(height: UiConstants.space4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.client_billing.invoice_ready, style: UiTypography.headline4b.textPrimary), - Text(t.client_billing.review_and_approve_subtitle, style: UiTypography.body2r.textSecondary), - ], - ), - UiIconButton.secondary( - icon: UiIcons.close, - onTap: () => Navigator.of(context).pop(), - ), - ], - ), - ], - ), - ); - } - Widget _buildInvoiceInfoCard() { return Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space1, children: [ - Text(invoice.title, style: UiTypography.headline4b.textPrimary), - Text(invoice.clientName, style: UiTypography.body2r.textSecondary), - const SizedBox(height: UiConstants.space4), _buildInfoRow(UiIcons.calendar, invoice.date), - const SizedBox(height: UiConstants.space2), - _buildInfoRow(UiIcons.clock, '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}'), - const SizedBox(height: UiConstants.space2), + _buildInfoRow( + UiIcons.clock, + '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}', + ), _buildInfoRow(UiIcons.mapPin, invoice.locationAddress), ], ); @@ -194,7 +157,11 @@ class _ShiftCompletionReviewPageState extends State { child: TextField( onChanged: (String val) => setState(() => searchQuery = val), decoration: InputDecoration( - icon: const Icon(UiIcons.search, size: 18, color: UiColors.iconSecondary), + icon: const Icon( + UiIcons.search, + size: 18, + color: UiColors.iconSecondary, + ), hintText: t.client_billing.workers_tab.search_hint, hintStyle: UiTypography.body2r.textSecondary, border: InputBorder.none, @@ -205,11 +172,17 @@ class _ShiftCompletionReviewPageState extends State { Row( children: [ Expanded( - child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0), + child: _buildTabButton( + t.client_billing.workers_tab.needs_review(count: 0), + 0, + ), ), const SizedBox(width: UiConstants.space3), Expanded( - child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1), + child: _buildTabButton( + t.client_billing.workers_tab.all(count: invoice.workersCount), + 1, + ), ), ], ), @@ -226,7 +199,9 @@ class _ShiftCompletionReviewPageState extends State { decoration: BoxDecoration( color: isSelected ? const Color(0xFF2563EB) : Colors.white, borderRadius: UiConstants.radiusMd, - border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border), + border: Border.all( + color: isSelected ? const Color(0xFF2563EB) : UiColors.border, + ), ), child: Center( child: Text( @@ -257,24 +232,44 @@ class _ShiftCompletionReviewPageState extends State { CircleAvatar( radius: 20, backgroundColor: UiColors.bgSecondary, - backgroundImage: worker.workerAvatarUrl != null ? NetworkImage(worker.workerAvatarUrl!) : null, - child: worker.workerAvatarUrl == null ? const Icon(UiIcons.user, size: 20, color: UiColors.iconSecondary) : null, + backgroundImage: worker.workerAvatarUrl != null + ? NetworkImage(worker.workerAvatarUrl!) + : null, + child: worker.workerAvatarUrl == null + ? const Icon( + UiIcons.user, + size: 20, + color: UiColors.iconSecondary, + ) + : null, ), const SizedBox(width: UiConstants.space3), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(worker.workerName, style: UiTypography.body1b.textPrimary), - Text(worker.roleName, style: UiTypography.footnote2r.textSecondary), + Text( + worker.workerName, + style: UiTypography.body1b.textPrimary, + ), + Text( + worker.roleName, + style: UiTypography.footnote2r.textSecondary, + ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text('\$${worker.totalAmount.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary), - Text('${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', style: UiTypography.footnote2r.textSecondary), + Text( + '\$${worker.totalAmount.toStringAsFixed(2)}', + style: UiTypography.body1b.textPrimary, + ), + Text( + '${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', + style: UiTypography.footnote2r.textSecondary, + ), ], ), ], @@ -283,17 +278,26 @@ class _ShiftCompletionReviewPageState extends State { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.white, borderRadius: UiConstants.radiusMd, border: Border.all(color: UiColors.border), ), - child: Text('${worker.startTime} - ${worker.endTime}', style: UiTypography.footnote2b.textPrimary), + child: Text( + '${worker.startTime} - ${worker.endTime}', + style: UiTypography.footnote2b.textPrimary, + ), ), const SizedBox(width: UiConstants.space2), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.white, borderRadius: UiConstants.radiusMd, @@ -301,22 +305,23 @@ class _ShiftCompletionReviewPageState extends State { ), child: Row( children: [ - const Icon(UiIcons.coffee, size: 12, color: UiColors.iconSecondary), + const Icon( + UiIcons.coffee, + size: 12, + color: UiColors.iconSecondary, + ), const SizedBox(width: 4), - Text('${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', style: UiTypography.footnote2r.textSecondary), + Text( + '${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', + style: UiTypography.footnote2r.textSecondary, + ), ], ), ), const Spacer(), - UiIconButton.secondary( - icon: UiIcons.edit, - onTap: () {}, - ), + UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}), const SizedBox(width: UiConstants.space2), - UiIconButton.secondary( - icon: UiIcons.warning, - onTap: () {}, - ), + UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}), ], ), ], @@ -333,9 +338,15 @@ class _ShiftCompletionReviewPageState extends State { text: t.client_billing.actions.approve_pay, leadingIcon: UiIcons.checkCircle, onPressed: () { - Modular.get().add(BillingInvoiceApproved(invoice.id)); + Modular.get().add( + BillingInvoiceApproved(invoice.id), + ); Modular.to.pop(); - UiSnackbar.show(context, message: t.client_billing.approved_success, type: UiSnackbarType.success); + UiSnackbar.show( + context, + message: t.client_billing.approved_success, + type: UiSnackbarType.success, + ); }, size: UiButtonSize.large, style: ElevatedButton.styleFrom( @@ -350,8 +361,8 @@ class _ShiftCompletionReviewPageState extends State { width: double.infinity, child: Container( decoration: BoxDecoration( - borderRadius: UiConstants.radiusMd, - border: Border.all(color: Colors.orange, width: 2), + borderRadius: UiConstants.radiusMd, + border: Border.all(color: Colors.orange, width: 2), ), child: UiButton.secondary( text: t.client_billing.actions.flag_review, @@ -409,7 +420,11 @@ class _ShiftCompletionReviewPageState extends State { ); Navigator.pop(dialogContext); Modular.to.pop(); - UiSnackbar.show(context, message: t.client_billing.flagged_success, type: UiSnackbarType.warning); + UiSnackbar.show( + context, + message: t.client_billing.flagged_success, + type: UiSnackbarType.warning, + ); } }, child: Text(t.client_billing.flag_dialog.button), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index 8e6469f1..7ae7b9bf 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; + import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../blocs/billing_state.dart'; @@ -14,7 +15,7 @@ class InvoiceReadyPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider.value( value: Modular.get()..add(const BillingLoadStarted()), - child: const InvoiceReadyView(), + child: const Placeholder(), ); } } @@ -25,13 +26,7 @@ class InvoiceReadyView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Invoices Ready'), - leading: UiIconButton.secondary( - icon: UiIcons.arrowLeft, - onTap: () => Modular.to.pop(), - ), - ), + appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true), body: BlocBuilder( builder: (context, state) { if (state.status == BillingStatus.loading) { @@ -43,12 +38,16 @@ class InvoiceReadyView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary), - const SizedBox(height: UiConstants.space4), - Text( - 'No invoices ready yet', - style: UiTypography.body1m.textSecondary, - ), + const Icon( + UiIcons.file, + size: 64, + color: UiColors.iconSecondary, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No invoices ready yet', + style: UiTypography.body1m.textSecondary, + ), ], ), ); @@ -96,26 +95,31 @@ class _InvoiceSummaryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: UiColors.success.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Text( 'READY', - style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success), + style: UiTypography.titleUppercase4b.copyWith( + color: UiColors.success, + ), ), ), - Text( - invoice.date, - style: UiTypography.footnote2r.textTertiary, - ), + Text(invoice.date, style: UiTypography.footnote2r.textTertiary), ], ), const SizedBox(height: 16), Text(invoice.title, style: UiTypography.title2b.textPrimary), const SizedBox(height: 8), - Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary), + Text( + invoice.locationAddress, + style: UiTypography.body2r.textSecondary, + ), const Divider(height: 32), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -123,8 +127,14 @@ class _InvoiceSummaryCard extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary), - Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary), + Text( + 'TOTAL AMOUNT', + style: UiTypography.titleUppercase4m.textSecondary, + ), + Text( + '\$${invoice.totalAmount.toStringAsFixed(2)}', + style: UiTypography.title2b.primary, + ), ], ), UiButton.primary( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart index 74c38c2e..468d6b85 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart @@ -25,7 +25,7 @@ class ActionsWidget extends StatelessWidget { title: i18n.rapid, subtitle: i18n.rapid_subtitle, icon: UiIcons.zap, - color: UiColors.tagError, + color: UiColors.tagError.withValues(alpha: 0.5), borderColor: UiColors.borderError.withValues(alpha: 0.3), iconBgColor: UiColors.white, iconColor: UiColors.textError, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart index 68b48b75..67fd318b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/utils/constants/order_types.dart @@ -12,18 +12,16 @@ class UiOrderType { /// Order type constants for the create order feature const List orderTypes = [ - /// TODO: FEATURE_NOT_YET_IMPLEMENTED - // UiOrderType( - // id: 'rapid', - // titleKey: 'client_create_order.types.rapid', - // descriptionKey: 'client_create_order.types.rapid_desc', - // ), + UiOrderType( + id: 'rapid', + titleKey: 'client_create_order.types.rapid', + descriptionKey: 'client_create_order.types.rapid_desc', + ), UiOrderType( id: 'one-time', titleKey: 'client_create_order.types.one_time', descriptionKey: 'client_create_order.types.one_time_desc', ), - UiOrderType( id: 'recurring', titleKey: 'client_create_order.types.recurring', From 119b6cc000afebc854bd4855d4c65398a9d935e2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 12:49:51 -0500 Subject: [PATCH 07/38] feat: introduce completion review UI components for actions, amount, info, search, and worker listing. --- .../lib/src/l10n/en.i18n.json | 4 +- .../lib/src/l10n/es.i18n.json | 4 +- .../pages/completion_review_page.dart | 415 ++---------------- .../pages/invoice_ready_page.dart | 11 +- .../completion_review_actions.dart | 90 ++++ .../completion_review_amount.dart | 42 ++ .../completion_review_info.dart | 36 ++ .../completion_review_search_and_tabs.dart | 89 ++++ .../completion_review_worker_card.dart | 126 ++++++ .../completion_review_workers_header.dart | 23 + 10 files changed, 453 insertions(+), 387 deletions(-) create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_workers_header.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 2ecac88b..e0544de4 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -541,8 +541,8 @@ "min_break": "min break" }, "actions": { - "approve_pay": "Approve & Process Payment", - "flag_review": "Flag for Review", + "approve_pay": "Approve", + "flag_review": "Review", "download_pdf": "Download Invoice PDF" }, "flag_dialog": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 00d8d979..599bfa23 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -536,8 +536,8 @@ "min_break": "min de descanso" }, "actions": { - "approve_pay": "Aprobar y Procesar Pago", - "flag_review": "Marcar para Revisi\u00f3n", + "approve_pay": "Aprobar", + "flag_review": "Revisi\u00f3n", "download_pdf": "Descargar PDF de Factura" }, "flag_dialog": { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 3856857f..d12efc0a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -1,12 +1,16 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import '../blocs/billing_bloc.dart'; -import '../blocs/billing_event.dart'; import '../models/billing_invoice_model.dart'; +import '../widgets/completion_review/completion_review_actions.dart'; +import '../widgets/completion_review/completion_review_amount.dart'; +import '../widgets/completion_review/completion_review_info.dart'; +import '../widgets/completion_review/completion_review_search_and_tabs.dart'; +import '../widgets/completion_review/completion_review_worker_card.dart'; +import '../widgets/completion_review/completion_review_workers_header.dart'; + class ShiftCompletionReviewPage extends StatefulWidget { const ShiftCompletionReviewPage({this.invoice, super.key}); @@ -26,7 +30,7 @@ class _ShiftCompletionReviewPageState extends State { void initState() { super.initState(); // Use widget.invoice if provided, else try to get from arguments - invoice = widget.invoice ?? Modular.args!.data as BillingInvoice; + invoice = widget.invoice ?? Modular.args.data as BillingInvoice; } @override @@ -46,390 +50,45 @@ class _ShiftCompletionReviewPageState extends State { showBackButton: true, ), body: SafeArea( - child: Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: UiConstants.space4), - _buildInvoiceInfoCard(), - const SizedBox(height: UiConstants.space4), - _buildAmountCard(), - const SizedBox(height: UiConstants.space6), - _buildWorkersHeader(), - const SizedBox(height: UiConstants.space4), - _buildSearchAndTabs(), - const SizedBox(height: UiConstants.space4), - ...filteredWorkers.map( - (BillingWorkerRecord worker) => _buildWorkerCard(worker), - ), - const SizedBox(height: UiConstants.space6), - _buildActionButtons(context), - const SizedBox(height: UiConstants.space4), - _buildDownloadLink(), - const SizedBox(height: UiConstants.space8), - ], - ), - ), - ), - ), - ); - } - - Widget _buildInvoiceInfoCard() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space1, - children: [ - _buildInfoRow(UiIcons.calendar, invoice.date), - _buildInfoRow( - UiIcons.clock, - '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}', - ), - _buildInfoRow(UiIcons.mapPin, invoice.locationAddress), - ], - ); - } - - Widget _buildInfoRow(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 16, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space3), - Text(text, style: UiTypography.body2r.textSecondary), - ], - ); - } - - Widget _buildAmountCard() { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: UiConstants.radiusLg, - border: Border.all(color: const Color(0xFFDBEAFE)), - ), - child: Column( - children: [ - Text( - t.client_billing.total_amount_label, - style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)), - ), - const SizedBox(height: UiConstants.space2), - Text( - '\$${invoice.totalAmount.toStringAsFixed(2)}', - style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40), - ), - const SizedBox(height: UiConstants.space1), - Text( - '${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}', - style: UiTypography.footnote2b.textSecondary, - ), - ], - ), - ); - } - - Widget _buildWorkersHeader() { - return Row( - children: [ - const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space2), - Text( - t.client_billing.workers_tab.title(count: invoice.workersCount), - style: UiTypography.title2b.textPrimary, - ), - ], - ); - } - - Widget _buildSearchAndTabs() { - return Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: UiConstants.radiusMd, - ), - child: TextField( - onChanged: (String val) => setState(() => searchQuery = val), - decoration: InputDecoration( - icon: const Icon( - UiIcons.search, - size: 18, - color: UiColors.iconSecondary, - ), - hintText: t.client_billing.workers_tab.search_hint, - hintStyle: UiTypography.body2r.textSecondary, - border: InputBorder.none, - ), - ), - ), - const SizedBox(height: UiConstants.space4), - Row( - children: [ - Expanded( - child: _buildTabButton( - t.client_billing.workers_tab.needs_review(count: 0), - 0, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _buildTabButton( - t.client_billing.workers_tab.all(count: invoice.workersCount), - 1, - ), - ), - ], - ), - ], - ); - } - - Widget _buildTabButton(String text, int index) { - final bool isSelected = selectedTab == index; - return GestureDetector( - onTap: () => setState(() => selectedTab = index), - child: Container( - height: 40, - decoration: BoxDecoration( - color: isSelected ? const Color(0xFF2563EB) : Colors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all( - color: isSelected ? const Color(0xFF2563EB) : UiColors.border, - ), - ), - child: Center( - child: Text( - text, - style: UiTypography.body2b.copyWith( - color: isSelected ? Colors.white : UiColors.textSecondary, - ), - ), - ), - ), - ); - } - - Widget _buildWorkerCard(BillingWorkerRecord worker) { - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), - ), - child: Column( - children: [ - Row( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 20, - backgroundColor: UiColors.bgSecondary, - backgroundImage: worker.workerAvatarUrl != null - ? NetworkImage(worker.workerAvatarUrl!) - : null, - child: worker.workerAvatarUrl == null - ? const Icon( - UiIcons.user, - size: 20, - color: UiColors.iconSecondary, - ) - : null, + const SizedBox(height: UiConstants.space4), + CompletionReviewInfo(invoice: invoice), + const SizedBox(height: UiConstants.space4), + CompletionReviewAmount(invoice: invoice), + const SizedBox(height: UiConstants.space6), + CompletionReviewWorkersHeader(workersCount: invoice.workersCount), + const SizedBox(height: UiConstants.space4), + CompletionReviewSearchAndTabs( + selectedTab: selectedTab, + workersCount: invoice.workersCount, + onTabChanged: (int index) => + setState(() => selectedTab = index), + onSearchChanged: (String val) => + setState(() => searchQuery = val), ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - worker.workerName, - style: UiTypography.body1b.textPrimary, - ), - Text( - worker.roleName, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '\$${worker.totalAmount.toStringAsFixed(2)}', - style: UiTypography.body1b.textPrimary, - ), - Text( - '${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', - style: UiTypography.footnote2r.textSecondary, - ), - ], + const SizedBox(height: UiConstants.space4), + ...filteredWorkers.map( + (BillingWorkerRecord worker) => + CompletionReviewWorkerCard(worker: worker), ), + const SizedBox(height: UiConstants.space4), ], ), - const SizedBox(height: UiConstants.space4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Text( - '${worker.startTime} - ${worker.endTime}', - style: UiTypography.footnote2b.textPrimary, - ), - ), - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: UiConstants.radiusMd, - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - const Icon( - UiIcons.coffee, - size: 12, - color: UiColors.iconSecondary, - ), - const SizedBox(width: 4), - Text( - '${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ), - const Spacer(), - UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}), - const SizedBox(width: UiConstants.space2), - UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}), - ], - ), - ], - ), - ); - } - - Widget _buildActionButtons(BuildContext context) { - return Column( - children: [ - SizedBox( - width: double.infinity, - child: UiButton.primary( - text: t.client_billing.actions.approve_pay, - leadingIcon: UiIcons.checkCircle, - onPressed: () { - Modular.get().add( - BillingInvoiceApproved(invoice.id), - ); - Modular.to.pop(); - UiSnackbar.show( - context, - message: t.client_billing.approved_success, - type: UiSnackbarType.success, - ); - }, - size: UiButtonSize.large, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF22C55E), - foregroundColor: Colors.white, - textStyle: UiTypography.body1b.copyWith(fontSize: 16), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - SizedBox( - width: double.infinity, - child: Container( - decoration: BoxDecoration( - borderRadius: UiConstants.radiusMd, - border: Border.all(color: Colors.orange, width: 2), - ), - child: UiButton.secondary( - text: t.client_billing.actions.flag_review, - leadingIcon: UiIcons.warning, - onPressed: () => _showFlagDialog(context), - size: UiButtonSize.large, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.orange, - side: BorderSide.none, - textStyle: UiTypography.body1b.copyWith(fontSize: 16), - ), - ), - ), - ), - ], - ); - } - - Widget _buildDownloadLink() { - return Center( - child: TextButton.icon( - onPressed: () {}, - icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)), - label: Text( - t.client_billing.actions.download_pdf, - style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)), ), ), - ); - } - - void _showFlagDialog(BuildContext context) { - final controller = TextEditingController(); - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(t.client_billing.flag_dialog.title), - content: TextField( - controller: controller, - decoration: InputDecoration( - hintText: t.client_billing.flag_dialog.hint, + bottomNavigationBar: Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), ), - maxLines: 3, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () { - if (controller.text.isNotEmpty) { - Modular.get().add( - BillingInvoiceDisputed(invoice.id, controller.text), - ); - Navigator.pop(dialogContext); - Modular.to.pop(); - UiSnackbar.show( - context, - message: t.client_billing.flagged_success, - type: UiSnackbarType.warning, - ); - } - }, - child: Text(t.client_billing.flag_dialog.button), - ), - ], + child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)), ), ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index 7ae7b9bf..b1b3bce4 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -15,7 +15,7 @@ class InvoiceReadyPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider.value( value: Modular.get()..add(const BillingLoadStarted()), - child: const Placeholder(), + child: const InvoiceReadyView(), ); } } @@ -28,7 +28,7 @@ class InvoiceReadyView extends StatelessWidget { return Scaffold( appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true), body: BlocBuilder( - builder: (context, state) { + builder: (BuildContext context, BillingState state) { if (state.status == BillingStatus.loading) { return const Center(child: CircularProgressIndicator()); } @@ -56,9 +56,10 @@ class InvoiceReadyView extends StatelessWidget { return ListView.separated( padding: const EdgeInsets.all(UiConstants.space5), itemCount: state.invoiceHistory.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - final invoice = state.invoiceHistory[index]; + separatorBuilder: (BuildContext context, int index) => + const SizedBox(height: 16), + itemBuilder: (BuildContext context, int index) { + final BillingInvoice invoice = state.invoiceHistory[index]; return _InvoiceSummaryCard(invoice: invoice); }, ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart new file mode 100644 index 00000000..c04ce60e --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -0,0 +1,90 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +import '../../blocs/billing_bloc.dart'; +import '../../blocs/billing_event.dart'; + +class CompletionReviewActions extends StatelessWidget { + const CompletionReviewActions({required this.invoiceId, super.key}); + + final String invoiceId; + + @override + Widget build(BuildContext context) { + return Row( + spacing: UiConstants.space2, + children: [ + Expanded( + child: UiButton.secondary( + text: t.client_billing.actions.flag_review, + leadingIcon: UiIcons.warning, + onPressed: () => _showFlagDialog(context), + size: UiButtonSize.large, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + side: BorderSide.none, + ), + ), + ), + Expanded( + child: UiButton.primary( + text: t.client_billing.actions.approve_pay, + leadingIcon: UiIcons.checkCircle, + onPressed: () { + Modular.get().add(BillingInvoiceApproved(invoiceId)); + Modular.to.pop(); + UiSnackbar.show( + context, + message: t.client_billing.approved_success, + type: UiSnackbarType.success, + ); + }, + size: UiButtonSize.large, + ), + ), + ], + ); + } + + void _showFlagDialog(BuildContext context) { + final TextEditingController controller = TextEditingController(); + showDialog( + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + title: Text(t.client_billing.flag_dialog.title), + content: TextField( + controller: controller, + decoration: InputDecoration( + hintText: t.client_billing.flag_dialog.hint, + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (controller.text.isNotEmpty) { + Modular.get().add( + BillingInvoiceDisputed(invoiceId, controller.text), + ); + Navigator.pop(dialogContext); + Modular.to.pop(); + UiSnackbar.show( + context, + message: t.client_billing.flagged_success, + type: UiSnackbarType.warning, + ); + } + }, + child: Text(t.client_billing.flag_dialog.button), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart new file mode 100644 index 00000000..48f81801 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart @@ -0,0 +1,42 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../models/billing_invoice_model.dart'; + +class CompletionReviewAmount extends StatelessWidget { + const CompletionReviewAmount({required this.invoice, super.key}); + + final BillingInvoice invoice; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: UiConstants.radiusLg, + border: Border.all(color: const Color(0xFFDBEAFE)), + ), + child: Column( + children: [ + Text( + t.client_billing.total_amount_label, + style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)), + ), + const SizedBox(height: UiConstants.space2), + Text( + '\$${invoice.totalAmount.toStringAsFixed(2)}', + style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40), + ), + const SizedBox(height: UiConstants.space1), + Text( + '${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}', + style: UiTypography.footnote2b.textSecondary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart new file mode 100644 index 00000000..6f40f884 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_info.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../models/billing_invoice_model.dart'; + +class CompletionReviewInfo extends StatelessWidget { + const CompletionReviewInfo({required this.invoice, super.key}); + + final BillingInvoice invoice; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space1, + children: [ + _buildInfoRow(UiIcons.calendar, invoice.date), + _buildInfoRow( + UiIcons.clock, + '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}', + ), + _buildInfoRow(UiIcons.mapPin, invoice.locationAddress), + ], + ); + } + + Widget _buildInfoRow(IconData icon, String text) { + return Row( + children: [ + Icon(icon, size: 16, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Text(text, style: UiTypography.body2r.textSecondary), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart new file mode 100644 index 00000000..eca816a3 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart @@ -0,0 +1,89 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class CompletionReviewSearchAndTabs extends StatelessWidget { + const CompletionReviewSearchAndTabs({ + required this.selectedTab, + required this.onTabChanged, + required this.onSearchChanged, + required this.workersCount, + super.key, + }); + + final int selectedTab; + final ValueChanged onTabChanged; + final ValueChanged onSearchChanged; + final int workersCount; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: UiConstants.radiusMd, + ), + child: TextField( + onChanged: onSearchChanged, + decoration: InputDecoration( + icon: const Icon( + UiIcons.search, + size: 18, + color: UiColors.iconSecondary, + ), + hintText: t.client_billing.workers_tab.search_hint, + hintStyle: UiTypography.body2r.textSecondary, + border: InputBorder.none, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + Row( + children: [ + Expanded( + child: _buildTabButton( + t.client_billing.workers_tab.needs_review(count: 0), + 0, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTabButton( + t.client_billing.workers_tab.all(count: workersCount), + 1, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTabButton(String text, int index) { + final bool isSelected = selectedTab == index; + return GestureDetector( + onTap: () => onTabChanged(index), + child: Container( + height: 40, + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2563EB) : Colors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all( + color: isSelected ? const Color(0xFF2563EB) : UiColors.border, + ), + ), + child: Center( + child: Text( + text, + style: UiTypography.body2b.copyWith( + color: isSelected ? Colors.white : UiColors.textSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart new file mode 100644 index 00000000..f2490ab2 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_worker_card.dart @@ -0,0 +1,126 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../../models/billing_invoice_model.dart'; + +class CompletionReviewWorkerCard extends StatelessWidget { + const CompletionReviewWorkerCard({required this.worker, super.key}); + + final BillingWorkerRecord worker; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 20, + backgroundColor: UiColors.bgSecondary, + backgroundImage: worker.workerAvatarUrl != null + ? NetworkImage(worker.workerAvatarUrl!) + : null, + child: worker.workerAvatarUrl == null + ? const Icon( + UiIcons.user, + size: 20, + color: UiColors.iconSecondary, + ) + : null, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + worker.workerName, + style: UiTypography.body1b.textPrimary, + ), + Text( + worker.roleName, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${worker.totalAmount.toStringAsFixed(2)}', + style: UiTypography.body1b.textPrimary, + ), + Text( + '${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Text( + '${worker.startTime} - ${worker.endTime}', + style: UiTypography.footnote2b.textPrimary, + ), + ), + const SizedBox(width: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + const Icon( + UiIcons.coffee, + size: 12, + color: UiColors.iconSecondary, + ), + const SizedBox(width: 4), + Text( + '${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + const Spacer(), + UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}), + const SizedBox(width: UiConstants.space2), + UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_workers_header.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_workers_header.dart new file mode 100644 index 00000000..c743dd99 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_workers_header.dart @@ -0,0 +1,23 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class CompletionReviewWorkersHeader extends StatelessWidget { + const CompletionReviewWorkersHeader({required this.workersCount, super.key}); + + final int workersCount; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + Text( + t.client_billing.workers_tab.title(count: workersCount), + style: UiTypography.title2b.textPrimary, + ), + ], + ); + } +} From 8c0708d2d306667b64d8ed1f0041cc080b3da4da Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 15:26:05 -0500 Subject: [PATCH 08/38] Refactor billing data parsing and filtering, update invoice queries, and remove the dedicated timesheets page. --- .../billing_connector_repository_impl.dart | 230 +++++++++++++----- .../src/presentation/blocs/billing_bloc.dart | 93 ++++--- .../src/presentation/pages/billing_page.dart | 77 +----- .../presentation/pages/timesheets_page.dart | 85 ------- .../completion_review_actions.dart | 4 +- .../widgets/invoice_history_section.dart | 9 +- .../widgets/pending_invoices_section.dart | 18 +- .../dataconnect/connector/invoice/queries.gql | 184 ++++++++++++++ 8 files changed, 414 insertions(+), 286 deletions(-) delete mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart index 7c955b71..63cda77d 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart @@ -6,16 +6,21 @@ import '../../domain/repositories/billing_connector_repository.dart'; /// Implementation of [BillingConnectorRepository]. class BillingConnectorRepositoryImpl implements BillingConnectorRepository { - BillingConnectorRepositoryImpl({ - dc.DataConnectService? service, - }) : _service = service ?? dc.DataConnectService.instance; + BillingConnectorRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; final dc.DataConnectService _service; @override - Future> getBankAccounts({required String businessId}) async { + Future> getBankAccounts({ + required String businessId, + }) async { return _service.run(() async { - final QueryResult result = await _service.connector + final QueryResult< + dc.GetAccountsByOwnerIdData, + dc.GetAccountsByOwnerIdVariables + > + result = await _service.connector .getAccountsByOwnerId(ownerId: businessId) .execute(); @@ -26,21 +31,32 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { @override Future getCurrentBillAmount({required String businessId}) async { return _service.run(() async { - final QueryResult result = await _service.connector + final QueryResult< + dc.ListInvoicesByBusinessIdData, + dc.ListInvoicesByBusinessIdVariables + > + result = await _service.connector .listInvoicesByBusinessId(businessId: businessId) .execute(); return result.data.invoices .map(_mapInvoice) .where((Invoice i) => i.status == InvoiceStatus.open) - .fold(0.0, (double sum, Invoice item) => sum + item.totalAmount); + .fold( + 0.0, + (double sum, Invoice item) => sum + item.totalAmount, + ); }); } @override Future> getInvoiceHistory({required String businessId}) async { return _service.run(() async { - final QueryResult result = await _service.connector + final QueryResult< + dc.ListInvoicesByBusinessIdData, + dc.ListInvoicesByBusinessIdVariables + > + result = await _service.connector .listInvoicesByBusinessId(businessId: businessId) .limit(20) .execute(); @@ -55,14 +71,22 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { @override Future> getPendingInvoices({required String businessId}) async { return _service.run(() async { - final QueryResult result = await _service.connector + final QueryResult< + dc.ListInvoicesByBusinessIdData, + dc.ListInvoicesByBusinessIdVariables + > + result = await _service.connector .listInvoicesByBusinessId(businessId: businessId) .execute(); return result.data.invoices .map(_mapInvoice) - .where((Invoice i) => - i.status != InvoiceStatus.paid) + .where( + (Invoice i) => + i.status != InvoiceStatus.paid && + i.status != InvoiceStatus.disputed && + i.status != InvoiceStatus.open, + ) .toList(); }); } @@ -76,19 +100,28 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { final DateTime now = DateTime.now(); final DateTime start; final DateTime end; - + if (period == BillingPeriod.week) { final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = DateTime(now.year, now.month, now.day) - .subtract(Duration(days: daysFromMonday)); + final DateTime monday = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: daysFromMonday)); start = monday; - end = monday.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + end = monday.add( + const Duration(days: 6, hours: 23, minutes: 59, seconds: 59), + ); } else { start = DateTime(now.year, now.month, 1); end = DateTime(now.year, now.month + 1, 0, 23, 59, 59); } - final QueryResult result = await _service.connector + final QueryResult< + dc.ListShiftRolesByBusinessAndDatesSummaryData, + dc.ListShiftRolesByBusinessAndDatesSummaryVariables + > + result = await _service.connector .listShiftRolesByBusinessAndDatesSummary( businessId: businessId, start: _service.toTimestamp(start), @@ -96,16 +129,18 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { ) .execute(); - final List shiftRoles = result.data.shiftRoles; + final List + shiftRoles = result.data.shiftRoles; if (shiftRoles.isEmpty) return []; final Map summary = {}; - for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role in shiftRoles) { + for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role + in shiftRoles) { final String roleId = role.roleId; final String roleName = role.role.name; final double hours = role.hours ?? 0.0; final double totalValue = role.totalValue ?? 0.0; - + final _RoleSummary? existing = summary[roleId]; if (existing == null) { summary[roleId] = _RoleSummary( @@ -123,14 +158,16 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { } return summary.values - .map((_RoleSummary item) => InvoiceItem( - id: item.roleId, - invoiceId: item.roleId, - staffId: item.roleName, - workHours: item.totalHours, - rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, - amount: item.totalValue, - )) + .map( + (_RoleSummary item) => InvoiceItem( + id: item.roleId, + invoiceId: item.roleId, + staffId: item.roleName, + workHours: item.totalHours, + rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, + amount: item.totalValue, + ), + ) .toList(); }); } @@ -146,7 +183,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { } @override - Future disputeInvoice({required String id, required String reason}) async { + Future disputeInvoice({ + required String id, + required String reason, + }) async { return _service.run(() async { await _service.connector .updateInvoice(id: id) @@ -159,36 +199,100 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { // --- MAPPERS --- Invoice _mapInvoice(dynamic invoice) { - final List rolesData = invoice.roles is List ? invoice.roles : []; - final List workers = rolesData.map((dynamic r) { - final Map role = r as Map; - - // Handle various possible key naming conventions in the JSON data - final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown'; - final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff'; - final double amount = (role['amount'] as num?)?.toDouble() ?? - (role['totalValue'] as num?)?.toDouble() ?? 0.0; - final double hours = (role['hours'] as num?)?.toDouble() ?? - (role['workHours'] as num?)?.toDouble() ?? - (role['totalHours'] as num?)?.toDouble() ?? 0.0; - final double rate = (role['rate'] as num?)?.toDouble() ?? - (role['hourlyRate'] as num?)?.toDouble() ?? 0.0; - - final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time']; - final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time']; + List workers = []; - return InvoiceWorker( - name: name, - role: roleTitle, - amount: amount, - hours: hours, - rate: rate, - checkIn: _service.toDateTime(checkInVal), - checkOut: _service.toDateTime(checkOutVal), - breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0, - avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'], - ); - }).toList(); + // Try to get workers from denormalized 'roles' field first + final List rolesData = invoice.roles is List + ? invoice.roles + : []; + if (rolesData.isNotEmpty) { + workers = rolesData.map((dynamic r) { + final Map role = r as Map; + + // Handle various possible key naming conventions in the JSON data + final String name = + role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown'; + final String roleTitle = + role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff'; + final double amount = + (role['amount'] as num?)?.toDouble() ?? + (role['totalValue'] as num?)?.toDouble() ?? + 0.0; + final double hours = + (role['hours'] as num?)?.toDouble() ?? + (role['workHours'] as num?)?.toDouble() ?? + (role['totalHours'] as num?)?.toDouble() ?? + 0.0; + final double rate = + (role['rate'] as num?)?.toDouble() ?? + (role['hourlyRate'] as num?)?.toDouble() ?? + 0.0; + + final dynamic checkInVal = + role['checkInTime'] ?? role['startTime'] ?? role['check_in_time']; + final dynamic checkOutVal = + role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time']; + + return InvoiceWorker( + name: name, + role: roleTitle, + amount: amount, + hours: hours, + rate: rate, + checkIn: _service.toDateTime(checkInVal), + checkOut: _service.toDateTime(checkOutVal), + breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0, + avatarUrl: + role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'], + ); + }).toList(); + } + // Fallback: If roles is empty, try to get workers from shift applications + else if (invoice.shift != null && + invoice.shift.applications_on_shift != null) { + final List apps = invoice.shift.applications_on_shift; + workers = apps.map((dynamic app) { + final String name = app.staff?.fullName ?? 'Unknown'; + final String roleTitle = app.shiftRole?.role?.name ?? 'Staff'; + final double amount = + (app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0; + final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0; + + // Calculate rate if not explicitly provided + double rate = 0.0; + if (hours > 0) { + rate = amount / hours; + } + + // Map break type to minutes + int breakMin = 0; + final String? breakType = app.shiftRole?.breakType?.toString(); + if (breakType != null) { + if (breakType.contains('10')) + breakMin = 10; + else if (breakType.contains('15')) + breakMin = 15; + else if (breakType.contains('30')) + breakMin = 30; + else if (breakType.contains('45')) + breakMin = 45; + else if (breakType.contains('60')) + breakMin = 60; + } + + return InvoiceWorker( + name: name, + role: roleTitle, + amount: amount, + hours: hours, + rate: rate, + checkIn: _service.toDateTime(app.checkInTime), + checkOut: _service.toDateTime(app.checkOutTime), + breakMinutes: breakMin, + avatarUrl: app.staff?.photoUrl, + ); + }).toList(); + } return Invoice( id: invoice.id, @@ -202,8 +306,10 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { issueDate: _service.toDateTime(invoice.issueDate)!, title: invoice.order?.eventName, clientName: invoice.business?.businessName, - locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address, - staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0), + locationAddress: + invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address, + staffCount: + invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0), totalHours: _calculateTotalHours(rolesData), workers: workers, ); @@ -256,10 +362,7 @@ class _RoleSummary { final double totalHours; final double totalValue; - _RoleSummary copyWith({ - double? totalHours, - double? totalValue, - }) { + _RoleSummary copyWith({double? totalHours, double? totalValue}) { return _RoleSummary( roleId: roleId, roleName: roleName, @@ -268,4 +371,3 @@ class _RoleSummary { ); } } - diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index 0206a3b9..07c1b98d 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -62,13 +62,13 @@ class BillingBloc extends Bloc action: () async { final List results = await Future.wait(>[ - _getCurrentBillAmount.call(), - _getSavingsAmount.call(), - _getPendingInvoices.call(), - _getInvoiceHistory.call(), - _getSpendingBreakdown.call(state.period), - _getBankAccounts.call(), - ]); + _getCurrentBillAmount.call(), + _getSavingsAmount.call(), + _getPendingInvoices.call(), + _getInvoiceHistory.call(), + _getSpendingBreakdown.call(state.period), + _getBankAccounts.call(), + ]); final double savings = results[1] as double; final List pendingInvoices = results[2] as List; @@ -78,10 +78,12 @@ class BillingBloc extends Bloc results[5] as List; // Map Domain Entities to Presentation Models - final List uiPendingInvoices = - pendingInvoices.map(_mapInvoiceToUiModel).toList(); - final List uiInvoiceHistory = - invoiceHistory.map(_mapInvoiceToUiModel).toList(); + final List uiPendingInvoices = pendingInvoices + .map(_mapInvoiceToUiModel) + .toList(); + final List uiInvoiceHistory = invoiceHistory + .map(_mapInvoiceToUiModel) + .toList(); final List uiSpendingBreakdown = _mapSpendingItemsToUiModel(spendingItems); final double periodTotal = uiSpendingBreakdown.fold( @@ -101,10 +103,8 @@ class BillingBloc extends Bloc ), ); }, - onError: (String errorKey) => state.copyWith( - status: BillingStatus.failure, - errorMessage: errorKey, - ), + onError: (String errorKey) => + state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), ); } @@ -115,8 +115,8 @@ class BillingBloc extends Bloc await handleError( emit: emit.call, action: () async { - final List spendingItems = - await _getSpendingBreakdown.call(event.period); + final List spendingItems = await _getSpendingBreakdown + .call(event.period); final List uiSpendingBreakdown = _mapSpendingItemsToUiModel(spendingItems); final double periodTotal = uiSpendingBreakdown.fold( @@ -131,10 +131,8 @@ class BillingBloc extends Bloc ), ); }, - onError: (String errorKey) => state.copyWith( - status: BillingStatus.failure, - errorMessage: errorKey, - ), + onError: (String errorKey) => + state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), ); } @@ -148,10 +146,8 @@ class BillingBloc extends Bloc await _approveInvoice.call(event.invoiceId); add(const BillingLoadStarted()); }, - onError: (String errorKey) => state.copyWith( - status: BillingStatus.failure, - errorMessage: errorKey, - ), + onError: (String errorKey) => + state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), ); } @@ -167,10 +163,8 @@ class BillingBloc extends Bloc ); add(const BillingLoadStarted()); }, - onError: (String errorKey) => state.copyWith( - status: BillingStatus.failure, - errorMessage: errorKey, - ), + onError: (String errorKey) => + state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), ); } @@ -180,15 +174,18 @@ class BillingBloc extends Bloc ? 'N/A' : formatter.format(invoice.issueDate!); - final List workers = invoice.workers.map((InvoiceWorker w) { + final List workers = invoice.workers.map(( + InvoiceWorker w, + ) { + final DateFormat timeFormat = DateFormat('h:mm a'); return BillingWorkerRecord( workerName: w.name, roleName: w.role, totalAmount: w.amount, hours: w.hours, rate: w.rate, - startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--', - endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--', + startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--', + endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--', breakMinutes: w.breakMinutes, workerAvatarUrl: w.avatarUrl, ); @@ -196,33 +193,35 @@ class BillingBloc extends Bloc String? overallStart; String? overallEnd; - - // Find valid times from workers instead of just taking the first one - final validStartTimes = workers - .where((w) => w.startTime != '--:--') - .map((w) => w.startTime) + + // Find valid times from actual DateTime checks to ensure chronological sorting + final List validCheckIns = invoice.workers + .where((InvoiceWorker w) => w.checkIn != null) + .map((InvoiceWorker w) => w.checkIn!) .toList(); - final validEndTimes = workers - .where((w) => w.endTime != '--:--') - .map((w) => w.endTime) + final List validCheckOuts = invoice.workers + .where((InvoiceWorker w) => w.checkOut != null) + .map((InvoiceWorker w) => w.checkOut!) .toList(); - if (validStartTimes.isNotEmpty) { - validStartTimes.sort(); - overallStart = validStartTimes.first; + final DateFormat timeFormat = DateFormat('h:mm a'); + + if (validCheckIns.isNotEmpty) { + validCheckIns.sort(); + overallStart = timeFormat.format(validCheckIns.first); } else if (workers.isNotEmpty) { overallStart = workers.first.startTime; } - if (validEndTimes.isNotEmpty) { - validEndTimes.sort(); - overallEnd = validEndTimes.last; + if (validCheckOuts.isNotEmpty) { + validCheckOuts.sort(); + overallEnd = timeFormat.format(validCheckOuts.last); } else if (workers.isNotEmpty) { overallEnd = workers.first.endTime; } return BillingInvoice( - id: invoice.invoiceNumber ?? invoice.id, + id: invoice.id, title: invoice.title ?? 'N/A', locationAddress: invoice.locationAddress ?? 'Remote', clientName: invoice.clientName ?? 'N/A', diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 20b2f0ef..f7a80aab 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -96,7 +96,7 @@ class _BillingViewState extends State { leading: Center( child: UiIconButton( icon: UiIcons.arrowLeft, - backgroundColor: UiColors.white.withOpacity(0.15), + backgroundColor: UiColors.white.withValues(alpha: 0.15), iconColor: UiColors.white, useBlur: true, size: 40, @@ -119,7 +119,7 @@ class _BillingViewState extends State { Text( t.client_billing.current_period, style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withOpacity(0.7), + color: UiColors.white.withValues(alpha: 0.7), ), ), const SizedBox(height: UiConstants.space1), @@ -232,77 +232,4 @@ class _BillingViewState extends State { ), ); } - - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: UiConstants.space12), - Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: UiColors.bgPopup, - shape: BoxShape.circle, - border: Border.all(color: UiColors.border), - ), - child: const Icon( - UiIcons.file, - size: 48, - color: UiColors.textSecondary, - ), - ), - const SizedBox(height: UiConstants.space4), - Text( - t.client_billing.no_invoices_period, - style: UiTypography.body1m.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ); - } -} - -class _InvoicesReadyBanner extends StatelessWidget { - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () => Modular.to.toInvoiceReady(), - child: Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.success.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.success.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(UiIcons.file, color: UiColors.success), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_billing.invoices_ready_title, - style: UiTypography.body1b.copyWith( - color: UiColors.success, - ), - ), - Text( - t.client_billing.invoices_ready_subtitle, - style: UiTypography.footnote2r.copyWith( - color: UiColors.success, - ), - ), - ], - ), - ), - const Icon(UiIcons.chevronRight, color: UiColors.success), - ], - ), - ), - ); - } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart deleted file mode 100644 index 9a14faa2..00000000 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/timesheets_page.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; - -class ClientTimesheetsPage extends StatelessWidget { - const ClientTimesheetsPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(context.t.client_billing.timesheets.title), - elevation: 0, - backgroundColor: UiColors.white, - foregroundColor: UiColors.primary, - ), - body: ListView.separated( - padding: const EdgeInsets.all(UiConstants.space5), - itemCount: 3, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - final workers = ['Sarah Miller', 'David Chen', 'Mike Ross']; - final roles = ['Cashier', 'Stocker', 'Event Support']; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.separatorPrimary), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(workers[index], style: UiTypography.body2b.textPrimary), - Text('\$84.00', style: UiTypography.body2b.primary), - ], - ), - Text(roles[index], style: UiTypography.footnote2r.textSecondary), - const SizedBox(height: 12), - Row( - children: [ - const Icon(UiIcons.clock, size: 14, color: UiColors.iconSecondary), - const SizedBox(width: 6), - Text('09:00 AM - 05:00 PM (8h)', style: UiTypography.footnote2r.textSecondary), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: UiButton.secondary( - text: context.t.client_billing.timesheets.decline_button, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - side: const BorderSide(color: UiColors.destructive), - ), - onPressed: () {}, - ), - ), - const SizedBox(width: 12), - Expanded( - child: UiButton.primary( - text: context.t.client_billing.timesheets.approve_button, - onPressed: () { - UiSnackbar.show( - context, - message: context.t.client_billing.timesheets.approved_message, - type: UiSnackbarType.success, - ); - }, - ), - ), - ], - ), - ], - ), - ); - }, - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index c04ce60e..c8047b53 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../../blocs/billing_bloc.dart'; import '../../blocs/billing_event.dart'; @@ -72,8 +73,7 @@ class CompletionReviewActions extends StatelessWidget { Modular.get().add( BillingInvoiceDisputed(invoiceId, controller.text), ); - Navigator.pop(dialogContext); - Modular.to.pop(); + Modular.to.toClientBilling(); UiSnackbar.show( context, message: t.client_billing.flagged_success, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index 55096618..fdbb5aa9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -25,7 +25,7 @@ class InvoiceHistorySection extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), + border: Border.all(color: UiColors.border.withValues(alpha: 0.5)), boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), @@ -77,7 +77,7 @@ class _InvoiceItem extends StatelessWidget { ), child: Icon( UiIcons.file, - color: UiColors.iconSecondary.withOpacity(0.6), + color: UiColors.iconSecondary.withValues(alpha: 0.6), size: 20, ), ), @@ -86,10 +86,7 @@ class _InvoiceItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - invoice.id, - style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), - ), + Text(invoice.title, style: UiTypography.body1r.textPrimary), Text( invoice.date, style: UiTypography.footnote2r.textSecondary, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 9a36922f..8c0387d1 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -25,7 +25,7 @@ class PendingInvoicesSection extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), + border: Border.all(color: UiColors.border.withValues(alpha: 0.5)), ), child: Row( children: [ @@ -41,9 +41,9 @@ class PendingInvoicesSection extends StatelessWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( - children: [ + children: [ Text( t.client_billing.awaiting_approval, style: UiTypography.body1b.textPrimary, @@ -79,7 +79,7 @@ class PendingInvoicesSection extends StatelessWidget { Icon( UiIcons.chevronRight, size: 20, - color: UiColors.iconSecondary.withOpacity(0.5), + color: UiColors.iconSecondary.withValues(alpha: 0.5), ), ], ), @@ -180,7 +180,7 @@ class PendingInvoiceCard extends StatelessWidget { Container( width: 1, height: 32, - color: UiColors.border.withOpacity(0.3), + color: UiColors.border.withValues(alpha: 0.3), ), Expanded( child: _buildStatItem( @@ -192,7 +192,7 @@ class PendingInvoiceCard extends StatelessWidget { Container( width: 1, height: 32, - color: UiColors.border.withOpacity(0.3), + color: UiColors.border.withValues(alpha: 0.3), ), Expanded( child: _buildStatItem( @@ -225,7 +225,11 @@ class PendingInvoiceCard extends StatelessWidget { Widget _buildStatItem(IconData icon, String value, String label) { return Column( children: [ - Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)), + Icon( + icon, + size: 20, + color: UiColors.iconSecondary.withValues(alpha: 0.8), + ), const SizedBox(height: 6), Text( value, diff --git a/backend/dataconnect/connector/invoice/queries.gql b/backend/dataconnect/connector/invoice/queries.gql index 9ee1df24..b2f39e22 100644 --- a/backend/dataconnect/connector/invoice/queries.gql +++ b/backend/dataconnect/connector/invoice/queries.gql @@ -61,6 +61,29 @@ query listInvoices( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -123,6 +146,29 @@ query getInvoiceById($id: UUID!) @auth(level: USER) { } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -194,6 +240,29 @@ query listInvoicesByVendorId( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -265,6 +334,29 @@ query listInvoicesByBusinessId( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -336,6 +428,29 @@ query listInvoicesByOrderId( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -407,6 +522,29 @@ query listInvoicesByStatus( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -501,6 +639,29 @@ query filterInvoices( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } @@ -576,5 +737,28 @@ query listOverdueInvoices( } } + + shift { + id + title + applications_on_shift { + id + status + checkInTime + checkOutTime + staff { + fullName + photoUrl + } + shiftRole { + role { + name + } + totalValue + hours + breakType + } + } + } } } From 5a79a4e517c7a65769ec7e683bc0f230c0de055d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 15:28:46 -0500 Subject: [PATCH 09/38] fix: Reorder invoice title and adjust vertical spacing in the pending invoices section. --- .../src/presentation/widgets/pending_invoices_section.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 8c0387d1..4ce1ee12 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -108,6 +108,8 @@ class PendingInvoiceCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(invoice.title, style: UiTypography.headline4b.textPrimary), + const SizedBox(height: UiConstants.space3), Row( children: [ const Icon( @@ -127,8 +129,6 @@ class PendingInvoiceCard extends StatelessWidget { ], ), const SizedBox(height: UiConstants.space2), - Text(invoice.title, style: UiTypography.headline4b.textPrimary), - const SizedBox(height: UiConstants.space1), Row( children: [ Text( From 76424b1b1fcf943a3a2801848303090cbc789513 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 16:02:10 -0500 Subject: [PATCH 10/38] refactor: extract invoice approval and dispute logic into a dedicated ShiftCompletionReviewBloc. --- .../lib/src/routing/client/navigator.dart | 2 +- .../lib/src/widgets/ui_button.dart | 31 +++-- .../billing/lib/src/billing_module.dart | 5 + .../src/presentation/blocs/billing_bloc.dart | 42 ------- .../src/presentation/blocs/billing_event.dart | 17 --- .../shift_completion_review_bloc.dart | 71 +++++++++++ .../shift_completion_review_event.dart | 37 ++++++ .../shift_completion_review_state.dart | 51 ++++++++ .../models/billing_invoice_model.dart | 4 +- .../pages/completion_review_page.dart | 2 + .../pages/invoice_ready_page.dart | 12 +- .../pages/pending_invoices_page.dart | 2 + .../completion_review_actions.dart | 110 +++++++++++------- .../completion_review_amount.dart | 4 +- 14 files changed, 273 insertions(+), 117 deletions(-) create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index a3650f69..a7ce7150 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -102,7 +102,7 @@ extension ClientNavigator on IModularNavigator { /// Navigates to the full list of invoices awaiting approval. void toAwaitingApproval({Object? arguments}) { - pushNamed(ClientPaths.awaitingApproval, arguments: arguments); + navigate(ClientPaths.awaitingApproval, arguments: arguments); } /// Navigates to the Invoice Ready page. 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 bfa6ceaf..44475be5 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 @@ -3,7 +3,6 @@ import '../ui_constants.dart'; /// A custom button widget with different variants and icon support. class UiButton extends StatelessWidget { - /// Creates a [UiButton] with a custom button builder. const UiButton({ super.key, @@ -17,6 +16,7 @@ class UiButton extends StatelessWidget { this.iconSize = 20, this.size = UiButtonSize.large, this.fullWidth = false, + this.isLoading = false, }) : assert( text != null || child != null, 'Either text or child must be provided', @@ -34,6 +34,7 @@ class UiButton extends StatelessWidget { this.iconSize = 20, this.size = UiButtonSize.large, this.fullWidth = false, + this.isLoading = false, }) : buttonBuilder = _elevatedButtonBuilder, assert( text != null || child != null, @@ -50,8 +51,9 @@ class UiButton extends StatelessWidget { this.trailingIcon, this.style, this.iconSize = 20, - this.size = UiButtonSize.large, + this.size = UiButtonSize.large, this.fullWidth = false, + this.isLoading = false, }) : buttonBuilder = _outlinedButtonBuilder, assert( text != null || child != null, @@ -70,6 +72,7 @@ class UiButton extends StatelessWidget { this.iconSize = 20, this.size = UiButtonSize.large, this.fullWidth = false, + this.isLoading = false, }) : buttonBuilder = _textButtonBuilder, assert( text != null || child != null, @@ -88,11 +91,13 @@ class UiButton extends StatelessWidget { this.iconSize = 20, this.size = UiButtonSize.large, this.fullWidth = false, + this.isLoading = false, }) : buttonBuilder = _textButtonBuilder, assert( text != null || child != null, 'Either text or child must be provided', ); + /// The text to display on the button. final String? text; @@ -129,18 +134,21 @@ class UiButton extends StatelessWidget { ) buttonBuilder; + /// Whether to show a loading indicator. + final bool isLoading; + @override /// Builds the button UI. Widget build(BuildContext context) { - final ButtonStyle mergedStyle = style != null - ? _getSizeStyle().merge(style) + final ButtonStyle mergedStyle = style != null + ? _getSizeStyle().merge(style) : _getSizeStyle(); - + final Widget button = buttonBuilder( context, - onPressed, + isLoading ? null : onPressed, mergedStyle, - _buildButtonContent(), + isLoading ? _buildLoadingContent() : _buildButtonContent(), ); if (fullWidth) { @@ -150,6 +158,15 @@ class UiButton extends StatelessWidget { return button; } + /// Builds the loading indicator. + Widget _buildLoadingContent() { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + /// Gets the style based on the button size. ButtonStyle _getSizeStyle() { switch (size) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index e1315aa8..9ad44e3e 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -12,6 +12,7 @@ import 'domain/usecases/get_spending_breakdown.dart'; import 'domain/usecases/approve_invoice.dart'; import 'domain/usecases/dispute_invoice.dart'; import 'presentation/blocs/billing_bloc.dart'; +import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart'; import 'presentation/models/billing_invoice_model.dart'; import 'presentation/pages/billing_page.dart'; import 'presentation/pages/completion_review_page.dart'; @@ -44,6 +45,10 @@ class BillingModule extends Module { getPendingInvoices: i.get(), getInvoiceHistory: i.get(), getSpendingBreakdown: i.get(), + ), + ); + i.add( + () => ShiftCompletionReviewBloc( approveInvoice: i.get(), disputeInvoice: i.get(), ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index 07c1b98d..e26088c2 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -8,8 +8,6 @@ import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_pending_invoices.dart'; import '../../domain/usecases/get_savings_amount.dart'; import '../../domain/usecases/get_spending_breakdown.dart'; -import '../../domain/usecases/approve_invoice.dart'; -import '../../domain/usecases/dispute_invoice.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; import 'billing_event.dart'; @@ -26,21 +24,15 @@ class BillingBloc extends Bloc required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, required GetSpendingBreakdownUseCase getSpendingBreakdown, - required ApproveInvoiceUseCase approveInvoice, - required DisputeInvoiceUseCase disputeInvoice, }) : _getBankAccounts = getBankAccounts, _getCurrentBillAmount = getCurrentBillAmount, _getSavingsAmount = getSavingsAmount, _getPendingInvoices = getPendingInvoices, _getInvoiceHistory = getInvoiceHistory, _getSpendingBreakdown = getSpendingBreakdown, - _approveInvoice = approveInvoice, - _disputeInvoice = disputeInvoice, super(const BillingState()) { on(_onLoadStarted); on(_onPeriodChanged); - on(_onInvoiceApproved); - on(_onInvoiceDisputed); } final GetBankAccountsUseCase _getBankAccounts; @@ -49,8 +41,6 @@ class BillingBloc extends Bloc final GetPendingInvoicesUseCase _getPendingInvoices; final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetSpendingBreakdownUseCase _getSpendingBreakdown; - final ApproveInvoiceUseCase _approveInvoice; - final DisputeInvoiceUseCase _disputeInvoice; Future _onLoadStarted( BillingLoadStarted event, @@ -136,38 +126,6 @@ class BillingBloc extends Bloc ); } - Future _onInvoiceApproved( - BillingInvoiceApproved event, - Emitter emit, - ) async { - await handleError( - emit: emit.call, - action: () async { - await _approveInvoice.call(event.invoiceId); - add(const BillingLoadStarted()); - }, - onError: (String errorKey) => - state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), - ); - } - - Future _onInvoiceDisputed( - BillingInvoiceDisputed event, - Emitter emit, - ) async { - await handleError( - emit: emit.call, - action: () async { - await _disputeInvoice.call( - DisputeInvoiceParams(id: event.invoiceId, reason: event.reason), - ); - add(const BillingLoadStarted()); - }, - onError: (String errorKey) => - state.copyWith(status: BillingStatus.failure, errorMessage: errorKey), - ); - } - BillingInvoice _mapInvoiceToUiModel(Invoice invoice) { final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = invoice.issueDate == null diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart index 929a1bf4..1b6996fe 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart @@ -24,20 +24,3 @@ class BillingPeriodChanged extends BillingEvent { @override List get props => [period]; } - -class BillingInvoiceApproved extends BillingEvent { - const BillingInvoiceApproved(this.invoiceId); - final String invoiceId; - - @override - List get props => [invoiceId]; -} - -class BillingInvoiceDisputed extends BillingEvent { - const BillingInvoiceDisputed(this.invoiceId, this.reason); - final String invoiceId; - final String reason; - - @override - List get props => [invoiceId, reason]; -} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart new file mode 100644 index 00000000..bbdb56f0 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart @@ -0,0 +1,71 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import '../../../domain/usecases/approve_invoice.dart'; +import '../../../domain/usecases/dispute_invoice.dart'; +import 'shift_completion_review_event.dart'; +import 'shift_completion_review_state.dart'; + +class ShiftCompletionReviewBloc + extends Bloc + with BlocErrorHandler { + ShiftCompletionReviewBloc({ + required ApproveInvoiceUseCase approveInvoice, + required DisputeInvoiceUseCase disputeInvoice, + }) : _approveInvoice = approveInvoice, + _disputeInvoice = disputeInvoice, + super(const ShiftCompletionReviewState()) { + on(_onApproved); + on(_onDisputed); + } + + final ApproveInvoiceUseCase _approveInvoice; + final DisputeInvoiceUseCase _disputeInvoice; + + Future _onApproved( + ShiftCompletionReviewApproved event, + Emitter emit, + ) async { + emit(state.copyWith(status: ShiftCompletionReviewStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + await _approveInvoice.call(event.invoiceId); + emit( + state.copyWith( + status: ShiftCompletionReviewStatus.success, + message: 'approved', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: ShiftCompletionReviewStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onDisputed( + ShiftCompletionReviewDisputed event, + Emitter emit, + ) async { + emit(state.copyWith(status: ShiftCompletionReviewStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + await _disputeInvoice.call( + DisputeInvoiceParams(id: event.invoiceId, reason: event.reason), + ); + emit( + state.copyWith( + status: ShiftCompletionReviewStatus.success, + message: 'disputed', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: ShiftCompletionReviewStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart new file mode 100644 index 00000000..10dde94b --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all shift completion review events. +abstract class ShiftCompletionReviewEvent extends Equatable { + /// Creates a [ShiftCompletionReviewEvent]. + const ShiftCompletionReviewEvent(); + + @override + List get props => []; +} + +/// Event triggered when an invoice is approved. +class ShiftCompletionReviewApproved extends ShiftCompletionReviewEvent { + /// Creates a [ShiftCompletionReviewApproved] event. + const ShiftCompletionReviewApproved(this.invoiceId); + + /// The ID of the invoice to approve. + final String invoiceId; + + @override + List get props => [invoiceId]; +} + +/// Event triggered when an invoice is disputed. +class ShiftCompletionReviewDisputed extends ShiftCompletionReviewEvent { + /// Creates a [ShiftCompletionReviewDisputed] event. + const ShiftCompletionReviewDisputed(this.invoiceId, this.reason); + + /// The ID of the invoice to dispute. + final String invoiceId; + + /// The reason for the dispute. + final String reason; + + @override + List get props => [invoiceId, reason]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart new file mode 100644 index 00000000..539fcc34 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart @@ -0,0 +1,51 @@ +import 'package:equatable/equatable.dart'; + +/// Status of the shift completion review process. +enum ShiftCompletionReviewStatus { + /// Initial state. + initial, + + /// Loading state. + loading, + + /// Success state. + success, + + /// Failure state. + failure, +} + +/// State for the [ShiftCompletionReviewBloc]. +class ShiftCompletionReviewState extends Equatable { + /// Creates a [ShiftCompletionReviewState]. + const ShiftCompletionReviewState({ + this.status = ShiftCompletionReviewStatus.initial, + this.message, + this.errorMessage, + }); + + /// Current status of the process. + final ShiftCompletionReviewStatus status; + + /// Success message (e.g., 'approved' or 'disputed'). + final String? message; + + /// Error message to display if [status] is [ShiftCompletionReviewStatus.failure]. + final String? errorMessage; + + /// Creates a copy of this state with the given fields replaced. + ShiftCompletionReviewState copyWith({ + ShiftCompletionReviewStatus? status, + String? message, + String? errorMessage, + }) { + return ShiftCompletionReviewState( + status: status ?? this.status, + message: message ?? this.message, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, message, errorMessage]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart index 6e8d8e11..9da0a498 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart @@ -11,7 +11,7 @@ class BillingInvoice extends Equatable { required this.workersCount, required this.totalHours, required this.status, - this.workers = const [], + this.workers = const [], this.startTime, this.endTime, }); @@ -70,7 +70,7 @@ class BillingWorkerRecord extends Equatable { final String? workerAvatarUrl; @override - List get props => [ + List get props => [ workerName, roleName, totalAmount, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index d12efc0a..1d49c5bb 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -1,6 +1,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../models/billing_invoice_model.dart'; @@ -48,6 +49,7 @@ class _ShiftCompletionReviewPageState extends State { title: invoice.title, subtitle: invoice.clientName, showBackButton: true, + onLeadingPressed: () => Modular.to.toAwaitingApproval(), ), body: SafeArea( child: SingleChildScrollView( diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index b1b3bce4..430b5193 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -37,7 +37,7 @@ class InvoiceReadyView extends StatelessWidget { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ const Icon( UiIcons.file, size: 64, @@ -81,7 +81,7 @@ class _InvoiceSummaryCard extends StatelessWidget { color: UiColors.white, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border), - boxShadow: [ + boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), blurRadius: 10, @@ -91,10 +91,10 @@ class _InvoiceSummaryCard extends StatelessWidget { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -124,10 +124,10 @@ class _InvoiceSummaryCard extends StatelessWidget { const Divider(height: 32), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ Text( 'TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index 246e2d08..d76b6d1a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; @@ -20,6 +21,7 @@ class PendingInvoicesPage extends StatelessWidget { appBar: UiAppBar( title: t.client_billing.awaiting_approval, showBackButton: true, + onLeadingPressed: () => Modular.to.toClientBilling(), ), body: _buildBody(context, state), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index c8047b53..5e5feb2a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -1,11 +1,13 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import '../../blocs/billing_bloc.dart'; -import '../../blocs/billing_event.dart'; +import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart'; +import '../../blocs/shift_completion_review/shift_completion_review_event.dart'; +import '../../blocs/shift_completion_review/shift_completion_review_state.dart'; class CompletionReviewActions extends StatelessWidget { const CompletionReviewActions({required this.invoiceId, super.key}); @@ -14,47 +16,80 @@ class CompletionReviewActions extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - spacing: UiConstants.space2, - children: [ - Expanded( - child: UiButton.secondary( - text: t.client_billing.actions.flag_review, - leadingIcon: UiIcons.warning, - onPressed: () => _showFlagDialog(context), - size: UiButtonSize.large, - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - side: BorderSide.none, - ), - ), - ), - Expanded( - child: UiButton.primary( - text: t.client_billing.actions.approve_pay, - leadingIcon: UiIcons.checkCircle, - onPressed: () { - Modular.get().add(BillingInvoiceApproved(invoiceId)); - Modular.to.pop(); - UiSnackbar.show( - context, - message: t.client_billing.approved_success, - type: UiSnackbarType.success, + return BlocProvider.value( + value: Modular.get(), + child: + BlocConsumer( + listener: (BuildContext context, ShiftCompletionReviewState state) { + if (state.status == ShiftCompletionReviewStatus.success) { + final String message = state.message == 'approved' + ? t.client_billing.approved_success + : t.client_billing.flagged_success; + final UiSnackbarType type = state.message == 'approved' + ? UiSnackbarType.success + : UiSnackbarType.warning; + + UiSnackbar.show(context, message: message, type: type); + Modular.to.toAwaitingApproval(); + } else if (state.status == ShiftCompletionReviewStatus.failure) { + UiSnackbar.show( + context, + message: state.errorMessage ?? t.errors.generic.unknown, + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ShiftCompletionReviewState state) { + final bool isLoading = + state.status == ShiftCompletionReviewStatus.loading; + + return Row( + spacing: UiConstants.space2, + children: [ + Expanded( + child: UiButton.secondary( + text: t.client_billing.actions.flag_review, + leadingIcon: UiIcons.warning, + onPressed: isLoading + ? null + : () => _showFlagDialog(context, state), + size: UiButtonSize.large, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + side: BorderSide.none, + ), + ), + ), + Expanded( + child: UiButton.primary( + text: t.client_billing.actions.approve_pay, + leadingIcon: isLoading ? null : UiIcons.checkCircle, + isLoading: isLoading, + onPressed: isLoading + ? null + : () { + BlocProvider.of( + context, + ).add(ShiftCompletionReviewApproved(invoiceId)); + }, + size: UiButtonSize.large, + ), + ), + ], ); }, - size: UiButtonSize.large, ), - ), - ], ); } - void _showFlagDialog(BuildContext context) { + void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) { final TextEditingController controller = TextEditingController(); showDialog( context: context, builder: (BuildContext dialogContext) => AlertDialog( title: Text(t.client_billing.flag_dialog.title), + surfaceTintColor: Colors.white, + backgroundColor: Colors.white, content: TextField( controller: controller, decoration: InputDecoration( @@ -70,15 +105,10 @@ class CompletionReviewActions extends StatelessWidget { TextButton( onPressed: () { if (controller.text.isNotEmpty) { - Modular.get().add( - BillingInvoiceDisputed(invoiceId, controller.text), - ); - Modular.to.toClientBilling(); - UiSnackbar.show( - context, - message: t.client_billing.flagged_success, - type: UiSnackbarType.warning, + BlocProvider.of(context).add( + ShiftCompletionReviewDisputed(invoiceId, controller.text), ); + Navigator.pop(dialogContext); } }, child: Text(t.client_billing.flag_dialog.button), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart index 48f81801..401fd8c0 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart @@ -15,9 +15,9 @@ class CompletionReviewAmount extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(UiConstants.space6), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, - border: Border.all(color: const Color(0xFFDBEAFE)), + border: Border.all(color: UiColors.border), ), child: Column( children: [ From c26128f1f269a3cbb06f455c8cb0c2999ef0732a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 17:02:44 -0500 Subject: [PATCH 11/38] feat: Enhance navigation robustness by redirecting to the appropriate home page on navigation errors or when popping the root route. --- .../lib/src/routing/client/navigator.dart | 49 ++-- .../src/routing/navigation_extensions.dart | 30 ++- .../core/lib/src/routing/staff/navigator.dart | 225 +++--------------- .../pages/pending_invoices_page.dart | 2 - 4 files changed, 80 insertions(+), 226 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index a7ce7150..dc057029 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -1,6 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../navigation_extensions.dart'; import 'route_paths.dart'; /// Typed navigation extension for the Client application. @@ -33,14 +34,14 @@ extension ClientNavigator on IModularNavigator { /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toClientRoot() { - navigate(ClientPaths.root); + safeNavigate(ClientPaths.root); } /// Navigates to the get started page. /// /// This is the landing page for unauthenticated users, offering login/signup options. void toClientGetStartedPage() { - navigate(ClientPaths.getStarted); + safeNavigate(ClientPaths.getStarted); } /// Navigates to the client sign-in page. @@ -48,7 +49,7 @@ extension ClientNavigator on IModularNavigator { /// This page allows existing clients to log in using email/password /// or social authentication providers. void toClientSignIn() { - pushNamed(ClientPaths.signIn); + safePush(ClientPaths.signIn); } /// Navigates to the client sign-up page. @@ -56,7 +57,7 @@ extension ClientNavigator on IModularNavigator { /// This page allows new clients to create an account and provides /// the initial registration form. void toClientSignUp() { - pushNamed(ClientPaths.signUp); + safePush(ClientPaths.signUp); } /// Navigates to the client home dashboard. @@ -66,7 +67,7 @@ extension ClientNavigator on IModularNavigator { /// /// Uses pushNamed to avoid trailing slash issues with navigate(). void toClientHome() { - navigate(ClientPaths.home); + safeNavigate(ClientPaths.home); } /// Navigates to the client main shell. @@ -74,7 +75,7 @@ extension ClientNavigator on IModularNavigator { /// This is the container with bottom navigation. Usually you'd navigate /// to a specific tab instead (like [toClientHome]). void toClientMain() { - navigate(ClientPaths.main); + safeNavigate(ClientPaths.main); } // ========================================================================== @@ -85,43 +86,43 @@ extension ClientNavigator on IModularNavigator { /// /// Displays workforce coverage analytics and metrics. void toClientCoverage() { - navigate(ClientPaths.coverage); + safeNavigate(ClientPaths.coverage); } /// Navigates to the Billing tab. /// /// Access billing history, invoices, and payment methods. void toClientBilling() { - navigate(ClientPaths.billing); + safeNavigate(ClientPaths.billing); } /// Navigates to the Completion Review page. void toCompletionReview({Object? arguments}) { - pushNamed(ClientPaths.completionReview, arguments: arguments); + safePush(ClientPaths.completionReview, arguments: arguments); } /// Navigates to the full list of invoices awaiting approval. void toAwaitingApproval({Object? arguments}) { - navigate(ClientPaths.awaitingApproval, arguments: arguments); + safeNavigate(ClientPaths.awaitingApproval, arguments: arguments); } /// Navigates to the Invoice Ready page. void toInvoiceReady() { - pushNamed(ClientPaths.invoiceReady); + safePush(ClientPaths.invoiceReady); } /// Navigates to the Orders tab. /// /// View and manage all shift orders with filtering and sorting. void toClientOrders() { - navigate(ClientPaths.orders); + safeNavigate(ClientPaths.orders); } /// Navigates to the Reports tab. /// /// Generate and view workforce reports and analytics. void toClientReports() { - navigate(ClientPaths.reports); + safeNavigate(ClientPaths.reports); } // ========================================================================== @@ -132,12 +133,12 @@ extension ClientNavigator on IModularNavigator { /// /// Manage account settings, notifications, and app preferences. void toClientSettings() { - pushNamed(ClientPaths.settings); + safePush(ClientPaths.settings); } /// Pushes the edit profile page. void toClientEditProfile() { - pushNamed('${ClientPaths.settings}/edit-profile'); + safePush('${ClientPaths.settings}/edit-profile'); } // ========================================================================== @@ -148,12 +149,12 @@ extension ClientNavigator on IModularNavigator { /// /// View and manage physical locations/hubs where staff are deployed. Future toClientHubs() async { - await pushNamed(ClientPaths.hubs); + await safePush(ClientPaths.hubs); } /// Navigates to the details of a specific hub. Future toHubDetails(Hub hub) { - return pushNamed( + return safePush( ClientPaths.hubDetails, arguments: {'hub': hub}, ); @@ -161,7 +162,7 @@ extension ClientNavigator on IModularNavigator { /// Navigates to the page to add a new hub or edit an existing one. Future toEditHub({Hub? hub}) async { - return pushNamed( + return safePush( ClientPaths.editHub, arguments: {'hub': hub}, // Some versions of Modular allow passing opaque here, but if not @@ -178,35 +179,35 @@ extension ClientNavigator on IModularNavigator { /// /// This is the starting point for all order creation flows. void toCreateOrder({Object? arguments}) { - navigate(ClientPaths.createOrder, arguments: arguments); + safeNavigate(ClientPaths.createOrder, arguments: arguments); } /// Pushes the rapid order creation flow. /// /// Quick shift creation with simplified inputs for urgent needs. void toCreateOrderRapid({Object? arguments}) { - pushNamed(ClientPaths.createOrderRapid, arguments: arguments); + safePush(ClientPaths.createOrderRapid, arguments: arguments); } /// Pushes the one-time order creation flow. /// /// Create a shift that occurs once at a specific date and time. void toCreateOrderOneTime({Object? arguments}) { - pushNamed(ClientPaths.createOrderOneTime, arguments: arguments); + safePush(ClientPaths.createOrderOneTime, arguments: arguments); } /// Pushes the recurring order creation flow. /// /// Create shifts that repeat on a defined schedule (daily, weekly, etc.). void toCreateOrderRecurring({Object? arguments}) { - pushNamed(ClientPaths.createOrderRecurring, arguments: arguments); + safePush(ClientPaths.createOrderRecurring, arguments: arguments); } /// Pushes the permanent order creation flow. /// /// Create a long-term or permanent staffing position. void toCreateOrderPermanent({Object? arguments}) { - pushNamed(ClientPaths.createOrderPermanent, arguments: arguments); + safePush(ClientPaths.createOrderPermanent, arguments: arguments); } // ========================================================================== @@ -215,7 +216,7 @@ extension ClientNavigator on IModularNavigator { /// Navigates to the order details page to a specific date. void toOrdersSpecificDate(DateTime date) { - navigate( + safeNavigate( ClientPaths.orders, arguments: {'initialDate': date}, ); diff --git a/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart b/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart index 697e3ed8..f520b823 100644 --- a/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart +++ b/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart @@ -1,4 +1,6 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'client/route_paths.dart'; +import 'staff/route_paths.dart'; /// Base navigation utilities extension for [IModularNavigator]. /// @@ -21,10 +23,7 @@ extension NavigationExtensions on IModularNavigator { /// * [arguments] - Optional arguments to pass to the route /// /// Returns `true` if navigation was successful, `false` otherwise. - Future safeNavigate( - String path, { - Object? arguments, - }) async { + Future safeNavigate(String path, {Object? arguments}) async { try { navigate(path, arguments: arguments); return true; @@ -32,6 +31,7 @@ extension NavigationExtensions on IModularNavigator { // In production, you might want to log this to a monitoring service // ignore: avoid_print print('Navigation error to $path: $e'); + navigateToHome(); return false; } } @@ -56,6 +56,7 @@ extension NavigationExtensions on IModularNavigator { // In production, you might want to log this to a monitoring service // ignore: avoid_print print('Push navigation error to $routeName: $e'); + navigateToHome(); return null; } } @@ -68,14 +69,31 @@ extension NavigationExtensions on IModularNavigator { navigate('/'); } - /// Pops the current route if possible. + /// Pops the current route if possible, otherwise navigates to home. /// - /// Returns `true` if a route was popped, `false` if already at root. + /// Returns `true` if a route was popped, `false` if it navigated to home. bool popSafe() { if (canPop()) { pop(); return true; } + navigateToHome(); return false; } + + /// Navigates to the designated home page based on the current context. + /// + /// Checks the current path to determine if the user is in the Client + /// or Staff portion of the application and routes to their respective home. + void navigateToHome() { + final String currentPath = Modular.to.path; + if (currentPath.contains('/client')) { + navigate(ClientPaths.home); + } else if (currentPath.contains('/worker') || + currentPath.contains('/staff')) { + navigate(StaffPaths.home); + } else { + navigate('/'); + } + } } diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index d3c1bfdd..f07a7fba 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -1,6 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../navigation_extensions.dart'; import 'route_paths.dart'; /// Typed navigation extension for the Staff application. @@ -33,76 +34,36 @@ extension StaffNavigator on IModularNavigator { /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toInitialPage() { - navigate(StaffPaths.root); + safeNavigate(StaffPaths.root); } - /// Navigates to the get started page. - /// - /// This is the landing page for unauthenticated users, offering login/signup options. void toGetStartedPage() { - navigate(StaffPaths.getStarted); + safeNavigate(StaffPaths.getStarted); } - /// Navigates to the phone verification page. - /// - /// Used for both login and signup flows to verify phone numbers via OTP. - /// - /// Parameters: - /// * [mode] - The authentication mode: 'login' or 'signup' - /// - /// The mode is passed as an argument and used by the verification page - /// to determine the appropriate flow. void toPhoneVerification(String mode) { - pushNamed( + safePush( StaffPaths.phoneVerification, arguments: {'mode': mode}, ); } - /// Navigates to the profile setup page, replacing the current route. - /// - /// This is typically called after successful phone verification for new - /// staff members. Uses pushReplacement to prevent going back to verification. void toProfileSetup() { - pushNamed(StaffPaths.profileSetup); + safePush(StaffPaths.profileSetup); } - // ========================================================================== - // MAIN NAVIGATION - // ========================================================================== - - /// Navigates to the staff home dashboard. - /// - /// This is the main landing page for authenticated staff members. - /// Displays shift cards, quick actions, and notifications. void toStaffHome() { pushNamedAndRemoveUntil(StaffPaths.home, (_) => false); } - /// Navigates to the benefits overview page. void toBenefits() { - pushNamed(StaffPaths.benefits); + safePush(StaffPaths.benefits); } - /// Navigates to the staff main shell. - /// - /// This is the container with bottom navigation. Navigates to home tab - /// by default. Usually you'd navigate to a specific tab instead. void toStaffMain() { pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); } - // ========================================================================== - // MAIN NAVIGATION TABS - // ========================================================================== - - /// Navigates to the Shifts tab. - /// - /// Browse available shifts, accepted shifts, and shift history. - /// - /// Parameters: - /// * [selectedDate] - Optional date to pre-select in the shifts view - /// * [initialTab] - Optional initial tab (via query parameter) void toShifts({ DateTime? selectedDate, String? initialTab, @@ -118,94 +79,47 @@ extension StaffNavigator on IModularNavigator { if (refreshAvailable == true) { args['refreshAvailable'] = true; } - navigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args); + safeNavigate(StaffPaths.shifts, arguments: args.isEmpty ? null : args); } - /// Navigates to the Payments tab. - /// - /// View payment history, earnings breakdown, and tax information. void toPayments() { pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false); } - /// Navigates to the Clock In tab. - /// - /// Access time tracking interface for active shifts. void toClockIn() { pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false); } - /// Navigates to the Profile tab. - /// - /// Manage personal information, documents, and preferences. void toProfile() { - navigate(StaffPaths.profile); + safeNavigate(StaffPaths.profile); } - // ========================================================================== - // SHIFT MANAGEMENT - // ========================================================================== - - /// Navigates to the shift details page for a specific shift. - /// - /// Displays comprehensive information about a shift including location, - /// time, pay rate, and action buttons for accepting/declining/applying. - /// - /// Parameters: - /// * [shift] - The shift entity to display details for - /// - /// The shift object is passed as an argument and can be retrieved - /// in the details page. void toShiftDetails(Shift shift) { - navigate(StaffPaths.shiftDetails(shift.id), arguments: shift); + safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift); } - // ========================================================================== - // ONBOARDING & PROFILE SECTIONS - // ========================================================================== - - /// Pushes the personal information page. - /// - /// Collect or edit basic personal information. void toPersonalInfo() { - pushNamed(StaffPaths.onboardingPersonalInfo); + safePush(StaffPaths.onboardingPersonalInfo); } - /// Pushes the preferred locations editing page. - /// - /// Allows staff to search and manage their preferred US work locations. void toPreferredLocations() { - pushNamed(StaffPaths.preferredLocations); + safePush(StaffPaths.preferredLocations); } - /// Pushes the emergency contact page. - /// - /// Manage emergency contact details for safety purposes. void toEmergencyContact() { - pushNamed(StaffPaths.emergencyContact); + safePush(StaffPaths.emergencyContact); } - /// Pushes the work experience page. - /// - /// Record previous work experience and qualifications. void toExperience() { - navigate(StaffPaths.experience); + safeNavigate(StaffPaths.experience); } - /// Pushes the attire preferences page. - /// - /// Record sizing and appearance information for uniform allocation. void toAttire() { - navigate(StaffPaths.attire); + safeNavigate(StaffPaths.attire); } - /// Pushes the attire capture page. - /// - /// Parameters: - /// * [item] - The attire item to capture - /// * [initialPhotoUrl] - Optional initial photo URL void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { - navigate( + safeNavigate( StaffPaths.attireCapture, arguments: { 'item': item, @@ -214,24 +128,12 @@ extension StaffNavigator on IModularNavigator { ); } - // ========================================================================== - // COMPLIANCE & DOCUMENTS - // ========================================================================== - - /// Pushes the documents management page. - /// - /// Upload and manage required documents like ID and work permits. void toDocuments() { - navigate(StaffPaths.documents); + safeNavigate(StaffPaths.documents); } - /// Pushes the document upload page. - /// - /// Parameters: - /// * [document] - The document metadata to upload - /// * [initialUrl] - Optional initial document URL void toDocumentUpload({required StaffDocument document, String? initialUrl}) { - navigate( + safeNavigate( StaffPaths.documentUpload, arguments: { 'document': document, @@ -240,124 +142,59 @@ extension StaffNavigator on IModularNavigator { ); } - /// Pushes the certificates management page. - /// - /// Manage professional certificates (e.g., food handling, CPR). void toCertificates() { - pushNamed(StaffPaths.certificates); + safePush(StaffPaths.certificates); } - // ========================================================================== - // FINANCIAL INFORMATION - // ========================================================================== - - /// Pushes the bank account information page. - /// - /// Manage banking details for direct deposit payments. void toBankAccount() { - pushNamed(StaffPaths.bankAccount); + safePush(StaffPaths.bankAccount); } - /// Pushes the tax forms page. - /// - /// Manage W-4, tax withholding, and related tax documents. void toTaxForms() { - pushNamed(StaffPaths.taxForms); + safePush(StaffPaths.taxForms); } - /// Pushes the time card page. - /// - /// View detailed time entries and timesheets. void toTimeCard() { - pushNamed(StaffPaths.timeCard); + safePush(StaffPaths.timeCard); } - // ========================================================================== - // SCHEDULING & AVAILABILITY - // ========================================================================== - - /// Pushes the availability management page. - /// - /// Define when the staff member is available to work. void toAvailability() { - pushNamed(StaffPaths.availability); + safePush(StaffPaths.availability); } - // ========================================================================== - // ADDITIONAL FEATURES - // ========================================================================== - - /// Pushes the KROW University page (placeholder). - /// - /// Access training materials and educational courses. void toKrowUniversity() { - pushNamed(StaffPaths.krowUniversity); + safePush(StaffPaths.krowUniversity); } - /// Pushes the trainings page (placeholder). - /// - /// View and complete required training modules. void toTrainings() { - pushNamed(StaffPaths.trainings); + safePush(StaffPaths.trainings); } - /// Pushes the leaderboard page (placeholder). - /// - /// View performance rankings and achievements. void toLeaderboard() { - pushNamed(StaffPaths.leaderboard); + safePush(StaffPaths.leaderboard); } - /// Pushes the FAQs page. - /// - /// Access frequently asked questions and help resources. void toFaqs() { - pushNamed(StaffPaths.faqs); + safePush(StaffPaths.faqs); } - // ========================================================================== - // PRIVACY & SECURITY - // ========================================================================== - - /// Navigates to the privacy and security settings page. - /// - /// Manage privacy preferences including: - /// * Location sharing settings - /// * View terms of service - /// * View privacy policy void toPrivacySecurity() { - pushNamed(StaffPaths.privacySecurity); + safePush(StaffPaths.privacySecurity); } - /// Navigates to the Terms of Service page. - /// - /// Display the full terms of service document in a dedicated page view. void toTermsOfService() { - pushNamed(StaffPaths.termsOfService); + safePush(StaffPaths.termsOfService); } - /// Navigates to the Privacy Policy page. - /// - /// Display the full privacy policy document in a dedicated page view. void toPrivacyPolicy() { - pushNamed(StaffPaths.privacyPolicy); + safePush(StaffPaths.privacyPolicy); } - // ========================================================================== - // MESSAGING & COMMUNICATION - // ========================================================================== - - /// Pushes the messages page (placeholder). - /// - /// Access internal messaging system. void toMessages() { - pushNamed(StaffPaths.messages); + safePush(StaffPaths.messages); } - /// Pushes the settings page (placeholder). - /// - /// General app settings and preferences. void toSettings() { - pushNamed(StaffPaths.settings); + safePush(StaffPaths.settings); } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index d76b6d1a..246e2d08 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -3,7 +3,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; @@ -21,7 +20,6 @@ class PendingInvoicesPage extends StatelessWidget { appBar: UiAppBar( title: t.client_billing.awaiting_approval, showBackButton: true, - onLeadingPressed: () => Modular.to.toClientBilling(), ), body: _buildBody(context, state), ); From 53b612851c267443da8e1775b0dc712532bdcee8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 17:23:53 -0500 Subject: [PATCH 12/38] refactor: enhance navigation robustness by introducing `popSafe` and `safePushNamedAndRemoveUntil` methods and updating their usage. --- .../lib/src/widgets/session_listener.dart | 6 +- .../lib/src/widgets/session_listener.dart | 7 +- .../lib/src/routing/client/navigator.dart | 2 +- .../src/routing/navigation_extensions.dart | 34 ++- .../core/lib/src/routing/staff/navigator.dart | 20 +- .../pages/pending_invoices_page.dart | 2 + .../presentation/blocs/client_main_cubit.dart | 10 +- .../widgets/rapid_order/rapid_order_view.dart | 2 +- .../widgets/reports_page/report_card.dart | 8 +- .../pages/phone_verification_page.dart | 6 +- .../pages/certificate_upload_page.dart | 4 +- .../widgets/certificates_header.dart | 16 +- .../src/presentation/pages/form_i9_page.dart | 242 ++++++++++++------ .../src/presentation/pages/form_w4_page.dart | 195 +++++++------- .../presentation/pages/tax_forms_page.dart | 36 +-- .../presentation/pages/bank_account_page.dart | 62 +++-- .../presentation/pages/time_card_page.dart | 35 ++- .../pages/attire_capture_page.dart | 33 +-- .../attire_capture_page/footer_section.dart | 3 +- .../pages/emergency_contact_screen.dart | 3 +- .../pages/language_selection_page.dart | 3 +- .../pages/personal_info_page.dart | 24 +- .../widgets/personal_info_form.dart | 50 ++-- .../pages/shift_details_page.dart | 12 +- 24 files changed, 481 insertions(+), 334 deletions(-) diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart index f481633b..cbae1627 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -74,7 +74,9 @@ class _SessionListenerState extends State { // Only show if not initial state (avoid showing on cold start) if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); - _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + _showSessionErrorDialog( + state.errorMessage ?? 'Session error occurred', + ); } else { _isInitialState = false; Modular.to.toClientGetStartedPage(); @@ -126,7 +128,7 @@ class _SessionListenerState extends State { TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.pop(); + Modular.to.popSafe(); }, child: const Text('Continue'), ), diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index de44a5e8..fa830a35 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -65,7 +65,6 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); - // Navigate to the main app Modular.to.toStaffHome(); break; @@ -75,7 +74,9 @@ class _SessionListenerState extends State { // Only show if not initial state (avoid showing on cold start) if (!_isInitialState) { debugPrint('[SessionListener] Session error: ${state.errorMessage}'); - _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + _showSessionErrorDialog( + state.errorMessage ?? 'Session error occurred', + ); } else { _isInitialState = false; Modular.to.toGetStartedPage(); @@ -127,7 +128,7 @@ class _SessionListenerState extends State { TextButton( onPressed: () { // User can retry by dismissing and continuing - Modular.to.pop(); + Modular.to.popSafe(); }, child: const Text('Continue'), ), diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index dc057029..a6a735ed 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -103,7 +103,7 @@ extension ClientNavigator on IModularNavigator { /// Navigates to the full list of invoices awaiting approval. void toAwaitingApproval({Object? arguments}) { - safeNavigate(ClientPaths.awaitingApproval, arguments: arguments); + safePush(ClientPaths.awaitingApproval, arguments: arguments); } /// Navigates to the Invoice Ready page. diff --git a/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart b/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart index f520b823..e06753a4 100644 --- a/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart +++ b/apps/mobile/packages/core/lib/src/routing/navigation_extensions.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'client/route_paths.dart'; import 'staff/route_paths.dart'; @@ -29,8 +30,8 @@ extension NavigationExtensions on IModularNavigator { return true; } catch (e) { // In production, you might want to log this to a monitoring service - // ignore: avoid_print - print('Navigation error to $path: $e'); + // ignore: avoid_debugPrint + debugPrint('Navigation error to $path: $e'); navigateToHome(); return false; } @@ -54,8 +55,29 @@ extension NavigationExtensions on IModularNavigator { return await pushNamed(routeName, arguments: arguments); } catch (e) { // In production, you might want to log this to a monitoring service - // ignore: avoid_print - print('Push navigation error to $routeName: $e'); + // ignore: avoid_debugPrint + debugPrint('Push navigation error to $routeName: $e'); + navigateToHome(); + return null; + } + } + + /// Safely pushes a named route and removes until a predicate is met. + Future safePushNamedAndRemoveUntil( + String routeName, + bool Function(Route) predicate, { + Object? arguments, + }) async { + try { + return await pushNamedAndRemoveUntil( + routeName, + predicate, + arguments: arguments, + ); + } catch (e) { + // In production, you might want to log this to a monitoring service + // ignore: avoid_debugPrint + debugPrint('PushNamedAndRemoveUntil error to $routeName: $e'); navigateToHome(); return null; } @@ -72,9 +94,9 @@ extension NavigationExtensions on IModularNavigator { /// Pops the current route if possible, otherwise navigates to home. /// /// Returns `true` if a route was popped, `false` if it navigated to home. - bool popSafe() { + bool popSafe([T? result]) { if (canPop()) { - pop(); + pop(result); return true; } navigateToHome(); diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index f07a7fba..42420650 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -53,7 +53,7 @@ extension StaffNavigator on IModularNavigator { } void toStaffHome() { - pushNamedAndRemoveUntil(StaffPaths.home, (_) => false); + safePushNamedAndRemoveUntil(StaffPaths.home, (_) => false); } void toBenefits() { @@ -61,7 +61,7 @@ extension StaffNavigator on IModularNavigator { } void toStaffMain() { - pushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); + safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false); } void toShifts({ @@ -83,11 +83,11 @@ extension StaffNavigator on IModularNavigator { } void toPayments() { - pushNamedAndRemoveUntil(StaffPaths.payments, (_) => false); + safePushNamedAndRemoveUntil(StaffPaths.payments, (_) => false); } void toClockIn() { - pushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false); + safePushNamedAndRemoveUntil(StaffPaths.clockIn, (_) => false); } void toProfile() { @@ -154,6 +154,18 @@ extension StaffNavigator on IModularNavigator { safePush(StaffPaths.taxForms); } + void toLanguageSelection() { + safePush(StaffPaths.languageSelection); + } + + void toFormI9() { + safeNavigate(StaffPaths.formI9); + } + + void toFormW4() { + safeNavigate(StaffPaths.formW4); + } + void toTimeCard() { safePush(StaffPaths.timeCard); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index 246e2d08..d76b6d1a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../blocs/billing_bloc.dart'; import '../blocs/billing_state.dart'; @@ -20,6 +21,7 @@ class PendingInvoicesPage extends StatelessWidget { appBar: UiAppBar( title: t.client_billing.awaiting_approval, showBackButton: true, + onLeadingPressed: () => Modular.to.toClientBilling(), ), body: _buildBody(context, state), ); diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart index 1b6683fd..98a8b2a9 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart @@ -46,19 +46,19 @@ class ClientMainCubit extends Cubit implements Disposable { switch (index) { case 0: - Modular.to.navigate(ClientPaths.coverage); + Modular.to.toClientCoverage(); break; case 1: - Modular.to.navigate(ClientPaths.billing); + Modular.to.toClientBilling(); break; case 2: - Modular.to.navigate(ClientPaths.home); + Modular.to.toClientHome(); break; case 3: - Modular.to.navigate(ClientPaths.orders); + Modular.to.toClientOrders(); break; case 4: - Modular.to.navigate(ClientPaths.reports); + Modular.to.toClientReports(); break; } // State update will happen via _onRouteChanged diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index 982ba0d2..ad687c7d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -80,7 +80,7 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { subtitle: labels.subtitle, date: dateStr, time: timeStr, - onBack: () => Modular.to.navigate(ClientPaths.createOrder), + onBack: () => Modular.to.toCreateOrder(), ), // Content diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart index 5ef00fcb..f5c73970 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart @@ -2,13 +2,13 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; /// A quick report card widget for navigating to specific reports. /// /// Displays an icon, name, and a quick navigation to a report page. /// Used in the quick reports grid of the reports page. class ReportCard extends StatelessWidget { - const ReportCard({ super.key, required this.icon, @@ -17,6 +17,7 @@ class ReportCard extends StatelessWidget { required this.iconColor, required this.route, }); + /// The icon to display for this report. final IconData icon; @@ -35,7 +36,7 @@ class ReportCard extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: () => Modular.to.pushNamed(route), + onTap: () => Modular.to.safePush(route), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -86,8 +87,7 @@ class ReportCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - context.t.client_reports.quick_reports - .two_click_export, + context.t.client_reports.quick_reports.two_click_export, style: const TextStyle( fontSize: 12, color: UiColors.textSecondary, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 8060a72f..d70eb8ad 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -17,9 +17,9 @@ import '../widgets/phone_verification_page/phone_input.dart'; /// This page coordinates the authentication flow by switching between /// [PhoneInput] and [OtpVerification] based on the current [AuthState]. class PhoneVerificationPage extends StatefulWidget { - /// Creates a [PhoneVerificationPage]. const PhoneVerificationPage({super.key, required this.mode}); + /// The authentication mode (login or signup). final AuthMode mode; @@ -123,10 +123,10 @@ class _PhoneVerificationPageState extends State { ); Future.delayed(const Duration(seconds: 5), () { if (!mounted) return; - Modular.to.navigate('/'); + Modular.to.toInitialPage(); }); } else if (messageKey == 'errors.auth.unauthorized_app') { - Modular.to.pop(); + Modular.to.popSafe(); } } }, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index fcb6cbcd..39259bfd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -155,7 +155,7 @@ class _CertificateUploadPageState extends State { message: t.staff_certificates.upload_modal.success_snackbar, type: UiSnackbarType.success, ); - Modular.to.pop(); // Returns to certificates list + Modular.to.popSafe(); // Returns to certificates list } else if (state.status == CertificateUploadStatus.failure) { UiSnackbar.show( context, @@ -170,7 +170,7 @@ class _CertificateUploadPageState extends State { title: widget.certificate?.name ?? t.staff_certificates.upload_modal.title, - onLeadingPressed: () => Modular.to.pop(), + onLeadingPressed: () => Modular.to.popSafe(), ), body: SingleChildScrollView( padding: const EdgeInsets.all(UiConstants.space5), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart index 121cb8b6..d270b5f4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart @@ -1,10 +1,10 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:core_localization/core_localization.dart'; class CertificatesHeader extends StatelessWidget { - const CertificatesHeader({ super.key, required this.completedCount, @@ -16,8 +16,12 @@ class CertificatesHeader extends StatelessWidget { @override Widget build(BuildContext context) { // Prevent division by zero - final double progressValue = totalCount == 0 ? 0 : completedCount / totalCount; - final int progressPercent = totalCount == 0 ? 0 : (progressValue * 100).round(); + final double progressValue = totalCount == 0 + ? 0 + : completedCount / totalCount; + final int progressPercent = totalCount == 0 + ? 0 + : (progressValue * 100).round(); return Container( padding: const EdgeInsets.fromLTRB( @@ -42,7 +46,7 @@ class CertificatesHeader extends StatelessWidget { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Modular.to.popSafe(), child: Container( width: UiConstants.space10, height: UiConstants.space10, @@ -101,7 +105,9 @@ class CertificatesHeader extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( t.staff_certificates.progress.verified_count( - completed: completedCount, total: totalCount), + completed: completedCount, + total: totalCount, + ), style: UiTypography.body3r.copyWith( color: UiColors.white.withValues(alpha: 0.7), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart index 0d306644..e36c6bcb 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart @@ -1,8 +1,10 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; +import 'package:flutter_modular/flutter_modular.dart' + hide ModularWatchExtension; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/i9/form_i9_cubit.dart'; @@ -18,11 +20,56 @@ class FormI9Page extends StatefulWidget { class _FormI9PageState extends State { final List _usStates = [ - 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', - 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', - 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', - 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', - 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY' + 'AL', + 'AK', + 'AZ', + 'AR', + 'CA', + 'CO', + 'CT', + 'DE', + 'FL', + 'GA', + 'HI', + 'ID', + 'IL', + 'IN', + 'IA', + 'KS', + 'KY', + 'LA', + 'ME', + 'MD', + 'MA', + 'MI', + 'MN', + 'MS', + 'MO', + 'MT', + 'NE', + 'NV', + 'NH', + 'NJ', + 'NM', + 'NY', + 'NC', + 'ND', + 'OH', + 'OK', + 'OR', + 'PA', + 'RI', + 'SC', + 'SD', + 'TN', + 'TX', + 'UT', + 'VT', + 'VA', + 'WA', + 'WV', + 'WI', + 'WY', ]; @override @@ -36,10 +83,19 @@ class _FormI9PageState extends State { } final List> _steps = >[ - {'title': 'Personal Information', 'subtitle': 'Name and contact details'}, + { + 'title': 'Personal Information', + 'subtitle': 'Name and contact details', + }, {'title': 'Address', 'subtitle': 'Your current address'}, - {'title': 'Citizenship Status', 'subtitle': 'Work authorization verification'}, - {'title': 'Review & Sign', 'subtitle': 'Confirm your information'}, + { + 'title': 'Citizenship Status', + 'subtitle': 'Work authorization verification', + }, + { + 'title': 'Review & Sign', + 'subtitle': 'Confirm your information', + }, ]; bool _canProceed(FormI9State state) { @@ -77,13 +133,27 @@ class _FormI9PageState extends State { @override Widget build(BuildContext context) { - final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; + final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of( + context, + ).staff_compliance.tax_forms.i9; final List> steps = >[ - {'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_sub}, - {'title': i18n.steps.address, 'subtitle': i18n.steps.address_sub}, - {'title': i18n.steps.citizenship, 'subtitle': i18n.steps.citizenship_sub}, - {'title': i18n.steps.review, 'subtitle': i18n.steps.review_sub}, + { + 'title': i18n.steps.personal, + 'subtitle': i18n.steps.personal_sub, + }, + { + 'title': i18n.steps.address, + 'subtitle': i18n.steps.address_sub, + }, + { + 'title': i18n.steps.citizenship, + 'subtitle': i18n.steps.citizenship_sub, + }, + { + 'title': i18n.steps.review, + 'subtitle': i18n.steps.review_sub, + }, ]; return BlocProvider.value( @@ -95,7 +165,9 @@ class _FormI9PageState extends State { } else if (state.status == FormI9Status.failure) { UiSnackbar.show( context, - message: translateErrorKey(state.errorMessage ?? 'An error occurred'), + message: translateErrorKey( + state.errorMessage ?? 'An error occurred', + ), type: UiSnackbarType.error, margin: const EdgeInsets.only( left: UiConstants.space4, @@ -106,7 +178,8 @@ class _FormI9PageState extends State { } }, builder: (BuildContext context, FormI9State state) { - if (state.status == FormI9Status.success) return _buildSuccessView(i18n); + if (state.status == FormI9Status.success) + return _buildSuccessView(i18n); return Scaffold( backgroundColor: UiColors.background, @@ -175,7 +248,7 @@ class _FormI9PageState extends State { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => Modular.to.pop(true), + onPressed: () => Modular.to.popSafe(true), style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, foregroundColor: UiColors.white, @@ -187,7 +260,11 @@ class _FormI9PageState extends State { ), elevation: 0, ), - child: Text(Translations.of(context).staff_compliance.tax_forms.w4.back_to_docs), + child: Text( + Translations.of( + context, + ).staff_compliance.tax_forms.w4.back_to_docs, + ), ), ), ], @@ -218,7 +295,7 @@ class _FormI9PageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Modular.to.popSafe(), child: const Icon( UiIcons.arrowLeft, color: UiColors.white, @@ -229,10 +306,7 @@ class _FormI9PageState extends State { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - i18n.title, - style: UiTypography.headline4m.white, - ), + Text(i18n.title, style: UiTypography.headline4m.white), Text( i18n.subtitle, style: UiTypography.body3r.copyWith( @@ -245,10 +319,9 @@ class _FormI9PageState extends State { ), const SizedBox(height: UiConstants.space6), Row( - children: steps - .asMap() - .entries - .map((MapEntry> entry) { + children: steps.asMap().entries.map(( + MapEntry> entry, + ) { final int idx = entry.key; final bool isLast = idx == steps.length - 1; return Expanded( @@ -384,7 +457,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.first_name, value: state.firstName, - onChanged: (String val) => context.read().firstNameChanged(val), + onChanged: (String val) => + context.read().firstNameChanged(val), placeholder: i18n.fields.hints.first_name, ), ), @@ -393,7 +467,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.last_name, value: state.lastName, - onChanged: (String val) => context.read().lastNameChanged(val), + onChanged: (String val) => + context.read().lastNameChanged(val), placeholder: i18n.fields.hints.last_name, ), ), @@ -406,7 +481,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.middle_initial, value: state.middleInitial, - onChanged: (String val) => context.read().middleInitialChanged(val), + onChanged: (String val) => + context.read().middleInitialChanged(val), placeholder: i18n.fields.hints.middle_initial, ), ), @@ -416,7 +492,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.other_last_names, value: state.otherLastNames, - onChanged: (String val) => context.read().otherLastNamesChanged(val), + onChanged: (String val) => + context.read().otherLastNamesChanged(val), placeholder: i18n.fields.maiden_name, ), ), @@ -426,7 +503,8 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.dob, value: state.dob, - onChanged: (String val) => context.read().dobChanged(val), + onChanged: (String val) => + context.read().dobChanged(val), placeholder: i18n.fields.hints.dob, keyboardType: TextInputType.datetime, ), @@ -446,7 +524,8 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.email, value: state.email, - onChanged: (String val) => context.read().emailChanged(val), + onChanged: (String val) => + context.read().emailChanged(val), keyboardType: TextInputType.emailAddress, placeholder: i18n.fields.hints.email, ), @@ -454,7 +533,8 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.phone, value: state.phone, - onChanged: (String val) => context.read().phoneChanged(val), + onChanged: (String val) => + context.read().phoneChanged(val), keyboardType: TextInputType.phone, placeholder: i18n.fields.hints.phone, ), @@ -472,14 +552,16 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.address_long, value: state.address, - onChanged: (String val) => context.read().addressChanged(val), + onChanged: (String val) => + context.read().addressChanged(val), placeholder: i18n.fields.hints.address, ), const SizedBox(height: UiConstants.space4), _buildTextField( i18n.fields.apt, value: state.aptNumber, - onChanged: (String val) => context.read().aptNumberChanged(val), + onChanged: (String val) => + context.read().aptNumberChanged(val), placeholder: i18n.fields.hints.apt, ), const SizedBox(height: UiConstants.space4), @@ -490,7 +572,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.city, value: state.city, - onChanged: (String val) => context.read().cityChanged(val), + onChanged: (String val) => + context.read().cityChanged(val), placeholder: i18n.fields.hints.city, ), ), @@ -541,7 +624,8 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.zip, value: state.zipCode, - onChanged: (String val) => context.read().zipCodeChanged(val), + onChanged: (String val) => + context.read().zipCodeChanged(val), placeholder: i18n.fields.hints.zip, keyboardType: TextInputType.number, ), @@ -557,24 +641,11 @@ class _FormI9PageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - i18n.fields.attestation, - style: UiTypography.body2m.textPrimary, - ), + Text(i18n.fields.attestation, style: UiTypography.body2m.textPrimary), const SizedBox(height: UiConstants.space6), - _buildRadioOption( - context, - state, - 'CITIZEN', - i18n.fields.citizen, - ), + _buildRadioOption(context, state, 'CITIZEN', i18n.fields.citizen), const SizedBox(height: UiConstants.space3), - _buildRadioOption( - context, - state, - 'NONCITIZEN', - i18n.fields.noncitizen, - ), + _buildRadioOption(context, state, 'NONCITIZEN', i18n.fields.noncitizen), const SizedBox(height: UiConstants.space3), _buildRadioOption( context, @@ -587,7 +658,8 @@ class _FormI9PageState extends State { child: _buildTextField( i18n.fields.uscis_number_label, value: state.uscisNumber, - onChanged: (String val) => context.read().uscisNumberChanged(val), + onChanged: (String val) => + context.read().uscisNumberChanged(val), placeholder: i18n.fields.hints.uscis, ), ) @@ -607,19 +679,25 @@ class _FormI9PageState extends State { _buildTextField( i18n.fields.admission_number, value: state.admissionNumber, - onChanged: (String val) => context.read().admissionNumberChanged(val), + onChanged: (String val) => context + .read() + .admissionNumberChanged(val), ), const SizedBox(height: UiConstants.space3), _buildTextField( i18n.fields.passport, value: state.passportNumber, - onChanged: (String val) => context.read().passportNumberChanged(val), + onChanged: (String val) => context + .read() + .passportNumberChanged(val), ), const SizedBox(height: UiConstants.space3), _buildTextField( i18n.fields.country, value: state.countryIssuance, - onChanged: (String val) => context.read().countryIssuanceChanged(val), + onChanged: (String val) => context + .read() + .countryIssuanceChanged(val), ), ], ), @@ -667,10 +745,7 @@ class _FormI9PageState extends State { ), const SizedBox(width: UiConstants.space3), Expanded( - child: Text( - label, - style: UiTypography.body2m.textPrimary, - ), + child: Text(label, style: UiTypography.body2m.textPrimary), ), ], ), @@ -704,8 +779,14 @@ class _FormI9PageState extends State { style: UiTypography.headline4m.copyWith(fontSize: 14), ), const SizedBox(height: UiConstants.space3), - _buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'), - _buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'), + _buildSummaryRow( + i18n.fields.summary_name, + '${state.firstName} ${state.lastName}', + ), + _buildSummaryRow( + i18n.fields.summary_address, + '${state.address}, ${state.city}', + ), _buildSummaryRow( i18n.fields.summary_ssn, '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', @@ -780,10 +861,7 @@ class _FormI9PageState extends State { style: const TextStyle(fontFamily: 'Cursive', fontSize: 18), ), const SizedBox(height: UiConstants.space4), - Text( - i18n.fields.date_label, - style: UiTypography.body3m.textSecondary, - ), + Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary), const SizedBox(height: UiConstants.space1 + 2), Container( width: double.infinity, @@ -811,10 +889,7 @@ class _FormI9PageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - label, - style: UiTypography.body2r.textSecondary, - ), + Text(label, style: UiTypography.body2r.textSecondary), Expanded( child: Text( value, @@ -828,7 +903,9 @@ class _FormI9PageState extends State { } String _getReadableCitizenship(String status) { - final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields; + final TranslationsStaffComplianceTaxFormsI9FieldsEn i18n = Translations.of( + context, + ).staff_compliance.tax_forms.i9.fields; switch (status) { case 'CITIZEN': return i18n.status_us_citizen; @@ -848,7 +925,9 @@ class _FormI9PageState extends State { FormI9State state, List> steps, ) { - final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of(context).staff_compliance.tax_forms.i9; + final TranslationsStaffComplianceTaxFormsI9En i18n = Translations.of( + context, + ).staff_compliance.tax_forms.i9; return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -883,10 +962,7 @@ class _FormI9PageState extends State { color: UiColors.textPrimary, ), const SizedBox(width: UiConstants.space2), - Text( - i18n.back, - style: UiTypography.body2r.textPrimary, - ), + Text(i18n.back, style: UiTypography.body2r.textPrimary), ], ), ), @@ -895,8 +971,8 @@ class _FormI9PageState extends State { Expanded( flex: 2, child: ElevatedButton( - onPressed: ( - _canProceed(state) && + onPressed: + (_canProceed(state) && state.status != FormI9Status.submitting) ? () => _handleNext(context, state.currentStep) : null, @@ -931,7 +1007,11 @@ class _FormI9PageState extends State { ), if (state.currentStep < steps.length - 1) ...[ const SizedBox(width: UiConstants.space2), - const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), + const Icon( + UiIcons.arrowRight, + size: 16, + color: UiColors.white, + ), ], ], ), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart index 1673a72a..a0b28ea0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart @@ -2,8 +2,10 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; +import 'package:flutter_modular/flutter_modular.dart' + hide ModularWatchExtension; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/w4/form_w4_cubit.dart'; @@ -84,7 +86,10 @@ class _FormW4PageState extends State { {'title': 'Filing Status', 'subtitle': 'Step 1c'}, {'title': 'Multiple Jobs', 'subtitle': 'Step 2 (optional)'}, {'title': 'Dependents', 'subtitle': 'Step 3'}, - {'title': 'Other Adjustments', 'subtitle': 'Step 4 (optional)'}, + { + 'title': 'Other Adjustments', + 'subtitle': 'Step 4 (optional)', + }, {'title': 'Review & Sign', 'subtitle': 'Step 5'}, ]; @@ -116,23 +121,41 @@ class _FormW4PageState extends State { context.read().previousStep(); } - int _totalCredits(FormW4State state) { - return (state.qualifyingChildren * 2000) + - (state.otherDependents * 500); + return (state.qualifyingChildren * 2000) + (state.otherDependents * 500); } @override Widget build(BuildContext context) { - final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; - + final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of( + context, + ).staff_compliance.tax_forms.w4; + final List> steps = >[ - {'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')}, - {'title': i18n.steps.filing, 'subtitle': i18n.step_label(current: '1c', total: '5')}, - {'title': i18n.steps.multiple_jobs, 'subtitle': i18n.step_label(current: '2', total: '5')}, - {'title': i18n.steps.dependents, 'subtitle': i18n.step_label(current: '3', total: '5')}, - {'title': i18n.steps.adjustments, 'subtitle': i18n.step_label(current: '4', total: '5')}, - {'title': i18n.steps.review, 'subtitle': i18n.step_label(current: '5', total: '5')}, + { + 'title': i18n.steps.personal, + 'subtitle': i18n.step_label(current: '1', total: '5'), + }, + { + 'title': i18n.steps.filing, + 'subtitle': i18n.step_label(current: '1c', total: '5'), + }, + { + 'title': i18n.steps.multiple_jobs, + 'subtitle': i18n.step_label(current: '2', total: '5'), + }, + { + 'title': i18n.steps.dependents, + 'subtitle': i18n.step_label(current: '3', total: '5'), + }, + { + 'title': i18n.steps.adjustments, + 'subtitle': i18n.step_label(current: '4', total: '5'), + }, + { + 'title': i18n.steps.review, + 'subtitle': i18n.step_label(current: '5', total: '5'), + }, ]; return BlocProvider.value( @@ -144,7 +167,9 @@ class _FormW4PageState extends State { } else if (state.status == FormW4Status.failure) { UiSnackbar.show( context, - message: translateErrorKey(state.errorMessage ?? 'An error occurred'), + message: translateErrorKey( + state.errorMessage ?? 'An error occurred', + ), type: UiSnackbarType.error, margin: const EdgeInsets.only( left: UiConstants.space4, @@ -155,7 +180,8 @@ class _FormW4PageState extends State { } }, builder: (BuildContext context, FormW4State state) { - if (state.status == FormW4Status.success) return _buildSuccessView(i18n); + if (state.status == FormW4Status.success) + return _buildSuccessView(i18n); return Scaffold( backgroundColor: UiColors.background, @@ -224,7 +250,7 @@ class _FormW4PageState extends State { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => Modular.to.pop(true), + onPressed: () => Modular.to.popSafe(true), style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, foregroundColor: UiColors.white, @@ -267,7 +293,7 @@ class _FormW4PageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Modular.to.popSafe(), child: const Icon( UiIcons.arrowLeft, color: UiColors.white, @@ -278,10 +304,7 @@ class _FormW4PageState extends State { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - i18n.title, - style: UiTypography.headline4m.white, - ), + Text(i18n.title, style: UiTypography.headline4m.white), Text( i18n.subtitle, style: UiTypography.body3r.copyWith( @@ -294,10 +317,9 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space6), Row( - children: steps - .asMap() - .entries - .map((MapEntry> entry) { + children: steps.asMap().entries.map(( + MapEntry> entry, + ) { final int idx = entry.key; final bool isLast = idx == steps.length - 1; return Expanded( @@ -434,7 +456,8 @@ class _FormW4PageState extends State { child: _buildTextField( i18n.fields.first_name, value: state.firstName, - onChanged: (String val) => context.read().firstNameChanged(val), + onChanged: (String val) => + context.read().firstNameChanged(val), placeholder: i18n.fields.placeholder_john, ), ), @@ -443,7 +466,8 @@ class _FormW4PageState extends State { child: _buildTextField( i18n.fields.last_name, value: state.lastName, - onChanged: (String val) => context.read().lastNameChanged(val), + onChanged: (String val) => + context.read().lastNameChanged(val), placeholder: i18n.fields.placeholder_smith, ), ), @@ -465,14 +489,16 @@ class _FormW4PageState extends State { _buildTextField( i18n.fields.address, value: state.address, - onChanged: (String val) => context.read().addressChanged(val), + onChanged: (String val) => + context.read().addressChanged(val), placeholder: i18n.fields.placeholder_address, ), const SizedBox(height: UiConstants.space4), _buildTextField( i18n.fields.city_state_zip, value: state.cityStateZip, - onChanged: (String val) => context.read().cityStateZipChanged(val), + onChanged: (String val) => + context.read().cityStateZipChanged(val), placeholder: i18n.fields.placeholder_csz, ), ], @@ -506,21 +532,9 @@ class _FormW4PageState extends State { ), ), const SizedBox(height: UiConstants.space6), - _buildRadioOption( - context, - state, - 'SINGLE', - i18n.fields.single, - null, - ), + _buildRadioOption(context, state, 'SINGLE', i18n.fields.single, null), const SizedBox(height: UiConstants.space3), - _buildRadioOption( - context, - state, - 'MARRIED', - i18n.fields.married, - null, - ), + _buildRadioOption(context, state, 'MARRIED', i18n.fields.married, null), const SizedBox(height: UiConstants.space3), _buildRadioOption( context, @@ -573,16 +587,10 @@ class _FormW4PageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: UiTypography.body2m.textPrimary, - ), + Text(label, style: UiTypography.body2m.textPrimary), if (subLabel != null) ...[ const SizedBox(height: 4), - Text( - subLabel, - style: UiTypography.body3r.textSecondary, - ), + Text(subLabel, style: UiTypography.body3r.textSecondary), ], ], ), @@ -609,11 +617,7 @@ class _FormW4PageState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - UiIcons.help, - color: UiColors.accent, - size: 20, - ), + const Icon(UiIcons.help, color: UiColors.accent, size: 20), const SizedBox(width: UiConstants.space3), Expanded( child: Column( @@ -636,8 +640,9 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space6), GestureDetector( - onTap: () => - context.read().multipleJobsChanged(!state.multipleJobs), + onTap: () => context.read().multipleJobsChanged( + !state.multipleJobs, + ), child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -654,10 +659,14 @@ class _FormW4PageState extends State { width: 24, height: 24, decoration: BoxDecoration( - color: state.multipleJobs ? UiColors.primary : UiColors.bgPopup, + color: state.multipleJobs + ? UiColors.primary + : UiColors.bgPopup, borderRadius: UiConstants.radiusMd, border: Border.all( - color: state.multipleJobs ? UiColors.primary : UiColors.border, + color: state.multipleJobs + ? UiColors.primary + : UiColors.border, ), ), child: state.multipleJobs @@ -741,7 +750,8 @@ class _FormW4PageState extends State { i18n.fields.children_under_17, i18n.fields.children_each, (FormW4State s) => s.qualifyingChildren, - (int val) => context.read().qualifyingChildrenChanged(val), + (int val) => + context.read().qualifyingChildrenChanged(val), ), const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -753,7 +763,8 @@ class _FormW4PageState extends State { i18n.fields.other_dependents, i18n.fields.other_each, (FormW4State s) => s.otherDependents, - (int val) => context.read().otherDependentsChanged(val), + (int val) => + context.read().otherDependentsChanged(val), ), ], ), @@ -775,9 +786,7 @@ class _FormW4PageState extends State { ), Text( '\$${_totalCredits(state)}', - style: UiTypography.body2b.textSuccess.copyWith( - fontSize: 18, - ), + style: UiTypography.body2b.textSuccess.copyWith(fontSize: 18), ), ], ), @@ -802,22 +811,14 @@ class _FormW4PageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: Text( - label, - style: UiTypography.body2m, - ), - ), + Expanded(child: Text(label, style: UiTypography.body2m)), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: UiColors.tagSuccess, borderRadius: UiConstants.radiusLg, ), - child: Text( - badge, - style: UiTypography.footnote2b.textSuccess, - ), + child: Text(badge, style: UiTypography.footnote2b.textSuccess), ), ], ), @@ -839,10 +840,7 @@ class _FormW4PageState extends State { ), ), ), - _buildCircleBtn( - UiIcons.add, - () => onChanged(value + 1), - ), + _buildCircleBtn(UiIcons.add, () => onChanged(value + 1)), ], ), ], @@ -881,7 +879,8 @@ class _FormW4PageState extends State { _buildTextField( i18n.fields.other_income, value: state.otherIncome, - onChanged: (String val) => context.read().otherIncomeChanged(val), + onChanged: (String val) => + context.read().otherIncomeChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -896,7 +895,8 @@ class _FormW4PageState extends State { _buildTextField( i18n.fields.deductions, value: state.deductions, - onChanged: (String val) => context.read().deductionsChanged(val), + onChanged: (String val) => + context.read().deductionsChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -911,7 +911,8 @@ class _FormW4PageState extends State { _buildTextField( i18n.fields.extra_withholding, value: state.extraWithholding, - onChanged: (String val) => context.read().extraWithholdingChanged(val), + onChanged: (String val) => + context.read().extraWithholdingChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -1019,10 +1020,7 @@ class _FormW4PageState extends State { style: const TextStyle(fontFamily: 'Cursive', fontSize: 18), ), const SizedBox(height: UiConstants.space4), - Text( - i18n.fields.date_label, - style: UiTypography.body3m.textSecondary, - ), + Text(i18n.fields.date_label, style: UiTypography.body3m.textSecondary), const SizedBox(height: 6), Container( width: double.infinity, @@ -1050,10 +1048,7 @@ class _FormW4PageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - label, - style: UiTypography.body2r.textSecondary, - ), + Text(label, style: UiTypography.body2r.textSecondary), Text( value, style: UiTypography.body2m.copyWith( @@ -1066,7 +1061,9 @@ class _FormW4PageState extends State { } String _getFilingStatusLabel(String status) { - final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields; + final TranslationsStaffComplianceTaxFormsW4FieldsEn i18n = Translations.of( + context, + ).staff_compliance.tax_forms.w4.fields; switch (status) { case 'SINGLE': return i18n.status_single; @@ -1084,7 +1081,9 @@ class _FormW4PageState extends State { FormW4State state, List> steps, ) { - final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of(context).staff_compliance.tax_forms.w4; + final TranslationsStaffComplianceTaxFormsW4En i18n = Translations.of( + context, + ).staff_compliance.tax_forms.w4; return Container( padding: const EdgeInsets.all(UiConstants.space4), @@ -1131,8 +1130,8 @@ class _FormW4PageState extends State { Expanded( flex: 2, child: ElevatedButton( - onPressed: ( - _canProceed(state) && + onPressed: + (_canProceed(state) && state.status != FormW4Status.submitting) ? () => _handleNext(context, state.currentStep) : null, @@ -1167,7 +1166,11 @@ class _FormW4PageState extends State { ), if (state.currentStep < steps.length - 1) ...[ const SizedBox(width: UiConstants.space2), - const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), + const Icon( + UiIcons.arrowRight, + size: 16, + color: UiColors.white, + ), ], ], ), @@ -1179,5 +1182,3 @@ class _FormW4PageState extends State { ); } } - - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index e8f3f52c..4ce3382b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/tax_forms/tax_forms_cubit.dart'; import '../blocs/tax_forms/tax_forms_state.dart'; @@ -18,7 +19,7 @@ class TaxFormsPage extends StatelessWidget { elevation: 0, leading: IconButton( icon: const Icon(UiIcons.arrowLeft, color: UiColors.bgPopup), - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), title: Text( 'Tax Documents', @@ -64,7 +65,9 @@ class TaxFormsPage extends StatelessWidget { if (state.status == TaxFormsStatus.failure) { return Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), child: Text( state.errorMessage != null ? translateErrorKey(state.errorMessage!) @@ -84,7 +87,9 @@ class TaxFormsPage extends StatelessWidget { spacing: UiConstants.space6, children: [ _buildProgressOverview(state.forms), - ...state.forms.map((TaxForm form) => _buildFormCard(context, form)), + ...state.forms.map( + (TaxForm form) => _buildFormCard(context, form), + ), _buildInfoCard(), ], ), @@ -118,10 +123,7 @@ class TaxFormsPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Document Progress', - style: UiTypography.body2m.textPrimary, - ), + Text('Document Progress', style: UiTypography.body2m.textPrimary), Text( '$completedCount/$totalCount', style: UiTypography.body2m.textSecondary, @@ -150,12 +152,18 @@ class TaxFormsPage extends StatelessWidget { return GestureDetector( onTap: () async { if (form is I9TaxForm) { - final Object? result = await Modular.to.pushNamed('i9', arguments: form); + final Object? result = await Modular.to.pushNamed( + 'i9', + arguments: form, + ); if (result == true && context.mounted) { await BlocProvider.of(context).loadTaxForms(); } } else if (form is W4TaxForm) { - final Object? result = await Modular.to.pushNamed('w4', arguments: form); + final Object? result = await Modular.to.pushNamed( + 'w4', + arguments: form, + ); if (result == true && context.mounted) { await BlocProvider.of(context).loadTaxForms(); } @@ -245,10 +253,7 @@ class TaxFormsPage extends StatelessWidget { color: UiColors.textSuccess, ), const SizedBox(width: UiConstants.space1), - Text( - 'Completed', - style: UiTypography.footnote2b.textSuccess, - ), + Text('Completed', style: UiTypography.footnote2b.textSuccess), ], ), ); @@ -267,10 +272,7 @@ class TaxFormsPage extends StatelessWidget { children: [ const Icon(UiIcons.clock, size: 12, color: UiColors.textWarning), const SizedBox(width: UiConstants.space1), - Text( - 'In Progress', - style: UiTypography.footnote2b.textWarning, - ), + Text('In Progress', style: UiTypography.footnote2b.textWarning), ], ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 2da73a16..ca23581e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; -// ignore: depend_on_referenced_packages +import 'package:krow_core/core.dart'; import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_state.dart'; @@ -32,12 +32,9 @@ class BankAccountPage extends StatelessWidget { elevation: 0, leading: IconButton( icon: const Icon(UiIcons.arrowLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.pop(), - ), - title: Text( - strings.title, - style: UiTypography.headline3m.textPrimary, + onPressed: () => Modular.to.popSafe(), ), + title: Text(strings.title, style: UiTypography.headline3m.textPrimary), bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), child: Container(color: UiColors.border, height: 1.0), @@ -61,7 +58,8 @@ class BankAccountPage extends StatelessWidget { // Error is already shown on the page itself (lines 73-85), no need for snackbar }, builder: (BuildContext context, BankAccountState state) { - if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) { + if (state.status == BankAccountStatus.loading && + state.accounts.isEmpty) { return const Center(child: CircularProgressIndicator()); } @@ -74,7 +72,9 @@ class BankAccountPage extends StatelessWidget { ? translateErrorKey(state.errorMessage!) : 'Error', textAlign: TextAlign.center, - style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), + style: UiTypography.body1m.copyWith( + color: UiColors.textSecondary, + ), ), ), ); @@ -92,11 +92,14 @@ class BankAccountPage extends StatelessWidget { const SizedBox(height: UiConstants.space6), Text( strings.linked_accounts, - style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), + style: UiTypography.headline4m.copyWith( + color: UiColors.textPrimary, + ), ), const SizedBox(height: UiConstants.space3), - ...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type - + ...state.accounts.map( + (StaffBankAccount a) => _buildAccountCard(a, strings), + ), // Added type // Add extra padding at bottom const SizedBox(height: UiConstants.space20), ], @@ -121,17 +124,23 @@ class BankAccountPage extends StatelessWidget { backgroundColor: UiColors.transparent, child: AddAccountForm( strings: strings, - onSubmit: (String bankName, String routing, String account, String type) { - cubit.addAccount( - bankName: bankName, - routingNumber: routing, - accountNumber: account, - type: type, - ); - Modular.to.pop(); - }, + onSubmit: + ( + String bankName, + String routing, + String account, + String type, + ) { + cubit.addAccount( + bankName: bankName, + routingNumber: routing, + accountNumber: account, + type: type, + ); + Modular.to.popSafe(); + }, onCancel: () { - Modular.to.pop(); + Modular.to.popSafe(); }, ), ); @@ -249,12 +258,13 @@ class BankAccountPage extends StatelessWidget { ), child: Row( children: [ - const Icon(UiIcons.check, size: UiConstants.iconXs, color: primaryColor), - const SizedBox(width: UiConstants.space1), - Text( - strings.primary, - style: UiTypography.body3m.primary, + const Icon( + UiIcons.check, + size: UiConstants.iconXs, + color: primaryColor, ), + const SizedBox(width: UiConstants.space1), + Text(strings.primary, style: UiTypography.body3m.primary), ], ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index ebce838b..5c66f590 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import '../blocs/time_card_bloc.dart'; import '../widgets/month_selector.dart'; import '../widgets/shift_history_list.dart'; @@ -37,8 +38,11 @@ class _TimeCardPageState extends State { backgroundColor: UiColors.bgPopup, elevation: 0, leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary), - onPressed: () => Modular.to.pop(), + icon: const Icon( + UiIcons.chevronLeft, + color: UiColors.iconSecondary, + ), + onPressed: () => Modular.to.popSafe(), ), title: Text( t.staff_time_card.title, @@ -69,7 +73,9 @@ class _TimeCardPageState extends State { child: Text( translateErrorKey(state.message), textAlign: TextAlign.center, - style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), + style: UiTypography.body1m.copyWith( + color: UiColors.textSecondary, + ), ), ), ); @@ -83,12 +89,22 @@ class _TimeCardPageState extends State { children: [ MonthSelector( selectedDate: state.selectedMonth, - onPreviousMonth: () => _bloc.add(ChangeMonth( - DateTime(state.selectedMonth.year, state.selectedMonth.month - 1), - )), - onNextMonth: () => _bloc.add(ChangeMonth( - DateTime(state.selectedMonth.year, state.selectedMonth.month + 1), - )), + onPreviousMonth: () => _bloc.add( + ChangeMonth( + DateTime( + state.selectedMonth.year, + state.selectedMonth.month - 1, + ), + ), + ), + onNextMonth: () => _bloc.add( + ChangeMonth( + DateTime( + state.selectedMonth.year, + state.selectedMonth.month + 1, + ), + ), + ), ), const SizedBox(height: UiConstants.space6), TimeCardSummary( @@ -108,4 +124,3 @@ class _TimeCardPageState extends State { ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 6c4e7880..65e61a9f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -135,7 +135,7 @@ class _AttireCapturePageState extends State { leading: const Icon(Icons.photo_library), title: Text(t.common.gallery), onTap: () { - Modular.to.pop(); + Modular.to.popSafe(); _onGallery(context); }, ), @@ -143,7 +143,7 @@ class _AttireCapturePageState extends State { leading: const Icon(Icons.camera_alt), title: Text(t.common.camera), onTap: () { - Modular.to.pop(); + Modular.to.popSafe(); _onCamera(context); }, ), @@ -215,10 +215,16 @@ class _AttireCapturePageState extends State { String _getStatusText(bool hasUploadedPhoto) { return switch (widget.item.verificationStatus) { - AttireVerificationStatus.approved => t.staff_profile_attire.capture.approved, - AttireVerificationStatus.rejected => t.staff_profile_attire.capture.rejected, - AttireVerificationStatus.pending => t.staff_profile_attire.capture.pending_verification, - _ => hasUploadedPhoto ? t.staff_profile_attire.capture.pending_verification : t.staff_profile_attire.capture.not_uploaded, + AttireVerificationStatus.approved => + t.staff_profile_attire.capture.approved, + AttireVerificationStatus.rejected => + t.staff_profile_attire.capture.rejected, + AttireVerificationStatus.pending => + t.staff_profile_attire.capture.pending_verification, + _ => + hasUploadedPhoto + ? t.staff_profile_attire.capture.pending_verification + : t.staff_profile_attire.capture.not_uploaded, }; } @@ -281,7 +287,9 @@ class _AttireCapturePageState extends State { child: Column( children: [ _FileTypesBanner( - message: t.staff_profile_attire.upload_file_types_banner, + message: t + .staff_profile_attire + .upload_file_types_banner, ), const SizedBox(height: UiConstants.space4), ImagePreviewSection( @@ -350,17 +358,10 @@ class _FileTypesBanner extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - UiIcons.info, - size: 20, - color: UiColors.primary, - ), + Icon(UiIcons.info, size: 20, color: UiColors.primary), const SizedBox(width: UiConstants.space3), Expanded( - child: Text( - message, - style: UiTypography.body2r.textSecondary, - ), + child: Text(message, style: UiTypography.body2r.textSecondary), ), ], ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart index 6f0b4c2e..895a803f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -2,6 +2,7 @@ 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:krow_core/core.dart'; import 'attire_upload_buttons.dart'; @@ -98,7 +99,7 @@ class FooterSection extends StatelessWidget { text: 'Submit Image', onPressed: () { if (updatedItem != null) { - Modular.to.pop(updatedItem); + Modular.to.popSafe(updatedItem); } }, ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart index 7a00374c..ca97ecb4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../blocs/emergency_contact_bloc.dart'; import '../widgets/emergency_contact_add_button.dart'; import '../widgets/emergency_contact_form_item.dart'; @@ -25,7 +26,7 @@ class EmergencyContactScreen extends StatelessWidget { elevation: 0, leading: IconButton( icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), title: Text( 'Emergency Contact', diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart index 01b902c5..ef4213d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; /// Language selection page for staff profile. /// @@ -30,7 +31,7 @@ class LanguageSelectionPage extends StatelessWidget { ); Modular.to - .pop(); // Close the language selection page after showing the snackbar + .popSafe(); // Close the language selection page after showing the snackbar } @override diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index 501bb577..67776f05 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../blocs/personal_info_bloc.dart'; import '../blocs/personal_info_state.dart'; import '../widgets/personal_info_content.dart'; - /// The Personal Info page for staff onboarding. /// /// This page allows staff members to view and edit their personal information @@ -22,7 +22,9 @@ class PersonalInfoPage extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsStaffOnboardingPersonalInfoEn i18n = Translations.of(context).staff.onboarding.personal_info; + final TranslationsStaffOnboardingPersonalInfoEn i18n = Translations.of( + context, + ).staff.onboarding.personal_info; return BlocProvider( create: (BuildContext context) => Modular.get(), child: BlocListener( @@ -33,7 +35,7 @@ class PersonalInfoPage extends StatelessWidget { message: i18n.save_success, type: UiSnackbarType.success, ); - Modular.to.pop(); + Modular.to.popSafe(); } else if (state.status == PersonalInfoStatus.error) { UiSnackbar.show( context, @@ -54,19 +56,13 @@ class PersonalInfoPage extends StatelessWidget { UiIcons.chevronLeft, color: UiColors.textSecondary, ), - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), tooltip: MaterialLocalizations.of(context).backButtonTooltip, ), - title: Text( - i18n.title, - style: UiTypography.title1m.textPrimary, - ), + title: Text(i18n.title, style: UiTypography.title1m.textPrimary), bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), - child: Container( - color: UiColors.border, - height: 1.0, - ), + child: Container(color: UiColors.border, height: 1.0), ), ), body: SafeArea( @@ -74,9 +70,7 @@ class PersonalInfoPage extends StatelessWidget { builder: (BuildContext context, PersonalInfoState state) { if (state.status == PersonalInfoStatus.loading || state.status == PersonalInfoStatus.initial) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } if (state.staff == null) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart index be7edfd8..5a50f6ef 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart @@ -11,7 +11,6 @@ import 'package:krow_core/core.dart'; /// The Preferred Locations row navigates to a dedicated Uber-style page. /// Uses only design system tokens for colors, typography, and spacing. class PersonalInfoForm extends StatelessWidget { - /// Creates a [PersonalInfoForm]. const PersonalInfoForm({ super.key, @@ -22,6 +21,7 @@ class PersonalInfoForm extends StatelessWidget { required this.currentLocations, this.enabled = true, }); + /// The staff member's full name (read-only). final String fullName; @@ -42,7 +42,8 @@ class PersonalInfoForm extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + final TranslationsStaffOnboardingPersonalInfoEn i18n = + t.staff.onboarding.personal_info; final String locationSummary = currentLocations.isEmpty ? i18n.locations_summary_none : currentLocations.join(', '); @@ -84,17 +85,13 @@ class PersonalInfoForm extends StatelessWidget { hint: i18n.locations_hint, icon: UiIcons.mapPin, enabled: enabled, - onTap: enabled - ? () => Modular.to.pushNamed(StaffPaths.preferredLocations) - : null, + onTap: enabled ? () => Modular.to.toPreferredLocations() : null, ), const SizedBox(height: UiConstants.space4), const _FieldLabel(text: 'Language'), const SizedBox(height: UiConstants.space2), - _LanguageSelector( - enabled: enabled, - ), + _LanguageSelector(enabled: enabled), ], ); } @@ -132,7 +129,9 @@ class _TappableRow extends StatelessWidget { color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), border: Border.all( - color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + color: enabled + ? UiColors.border + : UiColors.border.withValues(alpha: 0.5), ), ), child: Row( @@ -164,9 +163,7 @@ class _TappableRow extends StatelessWidget { /// A language selector widget that displays the current language and navigates to language selection page. class _LanguageSelector extends StatelessWidget { - const _LanguageSelector({ - this.enabled = true, - }); + const _LanguageSelector({this.enabled = true}); final bool enabled; @@ -176,9 +173,7 @@ class _LanguageSelector extends StatelessWidget { final String languageName = currentLocale == 'es' ? 'Español' : 'English'; return GestureDetector( - onTap: enabled - ? () => Modular.to.pushNamed(StaffPaths.languageSelection) - : null, + onTap: enabled ? () => Modular.to.toLanguageSelection() : null, child: Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space3, @@ -188,18 +183,21 @@ class _LanguageSelector extends StatelessWidget { color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), border: Border.all( - color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5), + color: enabled + ? UiColors.border + : UiColors.border.withValues(alpha: 0.5), ), ), child: Row( children: [ - const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary), + const Icon( + UiIcons.settings, + size: 18, + color: UiColors.iconSecondary, + ), const SizedBox(width: UiConstants.space3), Expanded( - child: Text( - languageName, - style: UiTypography.body2r.textPrimary, - ), + child: Text(languageName, style: UiTypography.body2r.textPrimary), ), if (enabled) const Icon( @@ -220,10 +218,7 @@ class _FieldLabel extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - text, - style: UiTypography.titleUppercase3m.textSecondary, - ); + return Text(text, style: UiTypography.titleUppercase3m.textSecondary); } } @@ -244,10 +239,7 @@ class _ReadOnlyField extends StatelessWidget { borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), border: Border.all(color: UiColors.border), ), - child: Text( - value, - style: UiTypography.body2r.textInactive, - ), + child: Text(value, style: UiTypography.body2r.textInactive), ); } } 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 2fe89c89..05449f48 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 @@ -227,12 +227,12 @@ class _ShiftDetailsPageState extends State { content: Text(i18n.message), actions: [ TextButton( - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), child: Text(Translations.of(context).common.cancel), ), TextButton( onPressed: () { - Modular.to.pop(); + Modular.to.popSafe(); _showApplyingDialog(context, shift); BlocProvider.of(context).add( BookShiftDetailsEvent( @@ -317,7 +317,11 @@ class _ShiftDetailsPageState extends State { children: [ const Icon(UiIcons.warning, color: UiColors.error), const SizedBox(width: UiConstants.space2), - Expanded(child: Text(context.t.staff_shifts.shift_details.eligibility_requirements)), + Expanded( + child: Text( + context.t.staff_shifts.shift_details.eligibility_requirements, + ), + ), ], ), content: Text( @@ -333,7 +337,7 @@ class _ShiftDetailsPageState extends State { text: "Go to Certificates", onPressed: () { Navigator.of(ctx).pop(); - Modular.to.pushNamed(StaffPaths.certificates); + Modular.to.toCertificates(); }, ), ], From 26c4327ec5b6c8dccb3bf3c72db508c26ffa8484 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 17:31:57 -0500 Subject: [PATCH 13/38] refactor: reload billing data and update awaiting approval navigation to be awaitable. --- .../packages/core/lib/src/routing/client/navigator.dart | 4 ++-- .../lib/src/presentation/pages/completion_review_page.dart | 1 - .../widgets/completion_review/completion_review_actions.dart | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index a6a735ed..54746a8d 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -102,8 +102,8 @@ extension ClientNavigator on IModularNavigator { } /// Navigates to the full list of invoices awaiting approval. - void toAwaitingApproval({Object? arguments}) { - safePush(ClientPaths.awaitingApproval, arguments: arguments); + Future toAwaitingApproval({Object? arguments}) { + return safePush(ClientPaths.awaitingApproval, arguments: arguments); } /// Navigates to the Invoice Ready page. diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 1d49c5bb..1090cfc6 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -49,7 +49,6 @@ class _ShiftCompletionReviewPageState extends State { title: invoice.title, subtitle: invoice.clientName, showBackButton: true, - onLeadingPressed: () => Modular.to.toAwaitingApproval(), ), body: SafeArea( child: SingleChildScrollView( diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index 5e5feb2a..2e34a81e 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -8,6 +8,8 @@ import 'package:krow_core/core.dart'; import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart'; import '../../blocs/shift_completion_review/shift_completion_review_event.dart'; import '../../blocs/shift_completion_review/shift_completion_review_state.dart'; +import '../../blocs/billing_bloc.dart'; +import '../../blocs/billing_event.dart'; class CompletionReviewActions extends StatelessWidget { const CompletionReviewActions({required this.invoiceId, super.key}); @@ -30,6 +32,7 @@ class CompletionReviewActions extends StatelessWidget { : UiSnackbarType.warning; UiSnackbar.show(context, message: message, type: type); + Modular.get().add(const BillingLoadStarted()); Modular.to.toAwaitingApproval(); } else if (state.status == ShiftCompletionReviewStatus.failure) { UiSnackbar.show( From 6902e845198c7f807bf8c750026d40ac26ed8f13 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 17:36:23 -0500 Subject: [PATCH 14/38] feat: Temporarily disable worker list display and refine amount widget styling on the completion review page. --- .../pages/completion_review_page.dart | 34 +++++++++---------- .../completion_review_amount.dart | 5 ++- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 1090cfc6..d9d48dd9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -1,10 +1,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; import '../models/billing_invoice_model.dart'; - import '../widgets/completion_review/completion_review_actions.dart'; import '../widgets/completion_review/completion_review_amount.dart'; import '../widgets/completion_review/completion_review_info.dart'; @@ -61,22 +59,22 @@ class _ShiftCompletionReviewPageState extends State { const SizedBox(height: UiConstants.space4), CompletionReviewAmount(invoice: invoice), const SizedBox(height: UiConstants.space6), - CompletionReviewWorkersHeader(workersCount: invoice.workersCount), - const SizedBox(height: UiConstants.space4), - CompletionReviewSearchAndTabs( - selectedTab: selectedTab, - workersCount: invoice.workersCount, - onTabChanged: (int index) => - setState(() => selectedTab = index), - onSearchChanged: (String val) => - setState(() => searchQuery = val), - ), - const SizedBox(height: UiConstants.space4), - ...filteredWorkers.map( - (BillingWorkerRecord worker) => - CompletionReviewWorkerCard(worker: worker), - ), - const SizedBox(height: UiConstants.space4), + // CompletionReviewWorkersHeader(workersCount: invoice.workersCount), + // const SizedBox(height: UiConstants.space4), + // CompletionReviewSearchAndTabs( + // selectedTab: selectedTab, + // workersCount: invoice.workersCount, + // onTabChanged: (int index) => + // setState(() => selectedTab = index), + // onSearchChanged: (String val) => + // setState(() => searchQuery = val), + // ), + // const SizedBox(height: UiConstants.space4), + // ...filteredWorkers.map( + // (BillingWorkerRecord worker) => + // CompletionReviewWorkerCard(worker: worker), + // ), + // const SizedBox(height: UiConstants.space4), ], ), ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart index 401fd8c0..5b69d84f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_amount.dart @@ -23,14 +23,13 @@ class CompletionReviewAmount extends StatelessWidget { children: [ Text( t.client_billing.total_amount_label, - style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)), + style: UiTypography.body2b.textPrimary, ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space1), Text( '\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40), ), - const SizedBox(height: UiConstants.space1), Text( '${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}', style: UiTypography.footnote2b.textSecondary, From d3f3b0f70e0116f46a817b9c1948d495c3b08977 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 17:57:40 -0500 Subject: [PATCH 15/38] docs: Update mobile development guidelines to enforce safe navigation and typed navigators with fallback mechanisms. --- docs/MOBILE/00-agent-development-rules.md | 7 ++++--- docs/MOBILE/01-architecture-principles.md | 8 +++++--- docs/MOBILE/02-design-system-usage.md | 6 ++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/MOBILE/00-agent-development-rules.md b/docs/MOBILE/00-agent-development-rules.md index 5ef0a8b7..b28a9cb2 100644 --- a/docs/MOBILE/00-agent-development-rules.md +++ b/docs/MOBILE/00-agent-development-rules.md @@ -42,9 +42,10 @@ Follow Dart standards strictly. * **Data Transformation**: MUST reside in **Repositories** (Data Connect layer). * *Forbidden*: Parsing JSON in the UI or Domain. * **Pattern**: Repositories map Data Connect models to Domain entities. -* **Navigation Logic**: MUST reside in **Flutter Modular Routes**. - * *Forbidden*: `Navigator.push` with hardcoded widgets. - * **Pattern**: Use named routes via `Modular.to.navigate()`. +* **Navigation Logic**: MUST reside in **Flutter Modular Routes** and use **Safe Navigation**. + * *Forbidden*: `Navigator.push` with hardcoded widgets or direct `Modular.to.pop()` / `Modular.to.navigate()`. + * **Pattern**: Use `popSafe()`, `safeNavigate()`, `safePush()`, etc., from `NavigationExtensions`. Prefer **Typed Navigators** (e.g., `Modular.to.toHome()`). + * **Fallback**: All navigation MUST have a fallback to the Home page implemented in `NavigationExtensions`. * **Session Management**: MUST reside in **DataConnectService** via **SessionHandlerMixin**. * **Pattern**: Automatic token refresh, auth state listening, and role-based validation. * **UI Reaction**: **SessionListener** widget wraps the entire app and responds to session state changes. diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index b37833ca..564df8e2 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -178,9 +178,11 @@ All backend access is unified through `DataConnectService` with integrated sessi - **Zero Direct Imports**: `import 'package:feature_a/...'` is FORBIDDEN inside `package:feature_b`. - Exception: Shared packages like `domain`, `core`, and `design_system` are always accessible. -- **Navigation**: Use named routes via Flutter Modular: - - **Pattern**: `Modular.to.navigate('route_name')` - - **Configuration**: Routes defined in `module.dart` files; constants in `paths.dart` +- **Navigation**: Use **Typed Navigators** and **Safe Navigation** via Flutter Modular: + - **Safe Methods**: ALWAYS use `safeNavigate()`, `safePush()`, `popSafe()`, and `safePushNamedAndRemoveUntil()` from `NavigationExtensions`. + - **Fallback**: All safe methods automatically fall back to the Home page (Staff or Client) if the target route is invalid or the operation fails. + - **Typed Navigator Pattern**: Prefer using typed methods on `Modular.to` (e.g., `Modular.to.toShiftDetails(id)`) which are implemented in `ClientNavigator` and `StaffNavigator` using these safe extensions. + - **Configuration**: Routes defined in `module.dart` files; constants in `paths.dart`. - **Data Sharing**: Features do not share state directly. Shared data accessed through: - **Domain Repositories**: Centralized data sources (e.g., `AuthRepository`) - **Session Stores**: `StaffSessionStore` and `ClientSessionStore` for app-wide user context diff --git a/docs/MOBILE/02-design-system-usage.md b/docs/MOBILE/02-design-system-usage.md index eeab7c90..0ad635e6 100644 --- a/docs/MOBILE/02-design-system-usage.md +++ b/docs/MOBILE/02-design-system-usage.md @@ -1,4 +1,4 @@ -# 03 - Design System Usage Guide +# 02 - Design System Usage Guide This document defines the mandatory standards for designing and implementing user interfaces across all applications and feature packages using the shared `apps/mobile/packages/design_system`. @@ -88,7 +88,9 @@ Padding( The design system provides "Smart Widgets" – these are high-level UI components that encapsulate both styling and standard behavior. - **Standard Widgets**: Prefer standard Flutter Material widgets (e.g., `ElevatedButton`) but styled via the central theme. -- **Custom Components**: Use `design_system` widgets for non-standard elements or wisgets that has similar design across various features, if provided. +- **Custom Components**: Use `design_system` widgets for non-standard elements or widgets that have similar design across various features, if provided. +- **Navigation in Widgets**: Widgets that trigger navigation (e.g., Back buttons in `UiAppBar`) MUST use `Modular.to.popSafe()` or typed navigator methods to prevent blank screens or unexpected application states during stack pops. + - **Composition**: Prefer composing standard widgets over creating deep inheritance hierarchies in features. ## 9. Theme Configuration & Usage From 7c701ded5f205b1fbfd7c2d37ecc2a594ad5c387 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 18:17:49 -0500 Subject: [PATCH 16/38] feat: Enhance authentication by refining user role validation during session handling and ensuring immediate ID token refresh after sign-in to prevent unauthenticated Data Connect SDK requests. --- .../mixins/session_handler_mixin.dart | 22 ++++++++++++++----- .../auth_repository_impl.dart | 10 +++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart index d04a2cb3..0ce10c6a 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -205,13 +205,23 @@ mixin SessionHandlerMixin { try { _emitSessionState(SessionState.loading()); - // Validate role if allowed roles are specified + // Validate role only when allowed roles are specified. if (_allowedRoles.isNotEmpty) { - final bool isAuthorized = await validateUserRole( - user.uid, - _allowedRoles, - ); - if (!isAuthorized) { + final String? userRole = await fetchUserRole(user.uid); + + if (userRole == null) { + // User has no record in the database yet. This is expected during + // the sign-up flow: Firebase Auth fires authStateChanges before the + // repository has created the PostgreSQL user record. Do NOT sign out — + // just emit unauthenticated and let the registration flow complete. + _emitSessionState(SessionState.unauthenticated()); + return; + } + + if (!_allowedRoles.contains(userRole)) { + // User IS in the database but has a role that is not permitted in + // this app (e.g., a STAFF-only user trying to use the Client app). + // Sign them out to force them to use the correct app. await auth.signOut(); _emitSessionState(SessionState.unauthenticated()); return; diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index f3a195fe..d21ac6ce 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -90,6 +90,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); } + // Force-refresh the ID token so the Data Connect SDK has a valid bearer + // token before we fire any mutations. Without this, there is a race + // condition where the gRPC layer sends the request unauthenticated + // immediately after account creation (gRPC code 16 UNAUTHENTICATED). + await firebaseUser.getIdToken(true); + // New user created successfully, proceed to create PostgreSQL entities return await _createBusinessAndUser( firebaseUser: firebaseUser, @@ -165,6 +171,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); } + // Force-refresh the ID token so the Data Connect SDK receives a valid + // bearer token before any subsequent Data Connect queries run. + await firebaseUser.getIdToken(true); + // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL final bool hasBusinessAccount = await _checkBusinessUserExists( firebaseUser.uid, From 1ab5ba2e6f2ecb38e1ebe1af662d9ad7752009f3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 22:32:54 -0500 Subject: [PATCH 17/38] feat: Implement Android keystore setup for secure signing in release builds and update documentation for local and CI/CD environments --- .gitignore | 2 +- apps/mobile/README.md | 57 ++++++++++++++++++- apps/mobile/apps/client/android/.gitignore | 3 +- .../apps/client/android/app/build.gradle.kts | 30 +++++++++- .../client/android/app/google-services.json | 8 +-- .../mobile/apps/client/android/key.properties | 9 +++ apps/mobile/apps/staff/android/.gitignore | 3 +- .../apps/staff/android/app/build.gradle.kts | 30 +++++++++- .../staff/android/app/google-services.json | 10 ++-- apps/mobile/apps/staff/android/key.properties | 9 +++ .../auth_repository_impl.dart | 1 - .../presentation/pages/tax_forms_page.dart | 41 ++----------- .../pages/attire_capture_page.dart | 7 +-- codemagic.yaml | 40 +++++++++++++ 14 files changed, 190 insertions(+), 60 deletions(-) create mode 100644 apps/mobile/apps/client/android/key.properties create mode 100644 apps/mobile/apps/staff/android/key.properties diff --git a/.gitignore b/.gitignore index e91fb146..53393800 100644 --- a/.gitignore +++ b/.gitignore @@ -119,7 +119,6 @@ vite.config.ts.timestamp-* # Android .gradle/ **/android/app/libs/ -**/android/key.properties **/android/local.properties # Build outputs @@ -193,3 +192,4 @@ AGENTS.md CLAUDE.md GEMINI.md TASKS.md +\n# Android Signing (Secure)\n**.jks\n**key.properties diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 2b6c2076..6f7afc3b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -26,7 +26,60 @@ The project is organized into modular packages to ensure separation of concerns ### 1. Prerequisites Ensure you have the Flutter SDK installed and configured. -### 2. Initial Setup +### 2. Android Keystore Setup (Required for Release Builds) + +To build release APKs/AABs for Android, you need the signing keystores. The keystore configuration (`key.properties`) is committed to the repository, but the actual keystore files are **not** for security reasons. + +#### For Local Development (First-time Setup) + +Contact your team lead to obtain the keystore files: +- `krow_with_us_client_dev.jks` - Client app signing keystore +- `krow_with_us_staff_dev.jks` - Staff app signing keystore + +Once you have the keystores, copy them to the respective app directories: + +```bash +# Copy keystores to their locations +cp krow_with_us_client_dev.jks apps/mobile/apps/client/android/app/ +cp krow_with_us_staff_dev.jks apps/mobile/apps/staff/android/app/ +``` + +The `key.properties` configuration files are already in the repository: +- `apps/mobile/apps/client/android/key.properties` +- `apps/mobile/apps/staff/android/key.properties` + +No manual property file creation is needed — just place the `.jks` files in the correct locations. + +#### For CI/CD (CodeMagic) + +CodeMagic uses a native keystore management system. Follow these steps: + +**Step 1: Upload Keystores to CodeMagic** +1. Go to **CodeMagic Team Settings** → **Code signing identities** → **Android keystores** +2. Upload the keystore files with these **Reference names** (important!): + - `krow_client_dev` (for dev builds) + - `krow_client_staging` (for staging builds) + - `krow_client_prod` (for production builds) + - `krow_staff_dev` (for dev builds) + - `krow_staff_staging` (for staging builds) + - `krow_staff_prod` (for production builds) +3. When uploading, enter the keystore password, key alias, and key password for each keystore + +**Step 2: Automatic Environment Variables** +CodeMagic automatically injects the following environment variables based on the keystore reference: +- `CM_KEYSTORE_PATH_CLIENT` / `CM_KEYSTORE_PATH_STAFF` - Path to the keystore file +- `CM_KEYSTORE_PASSWORD_CLIENT` / `CM_KEYSTORE_PASSWORD_STAFF` - Keystore password +- `CM_KEY_ALIAS_CLIENT` / `CM_KEY_ALIAS_STAFF` - Key alias +- `CM_KEY_PASSWORD_CLIENT` / `CM_KEY_PASSWORD_STAFF` - Key password + +**Step 3: Build Configuration** +The `build.gradle.kts` files are already configured to: +- Use CodeMagic environment variables when running in CI (`CI=true`) +- Fall back to `key.properties` for local development + +Reference: [CodeMagic Android Signing Documentation](https://docs.codemagic.io/yaml-code-signing/signing-android/) + +### 3. Initial Setup Run the following command from the **project root** to install Melos, bootstrap all packages, generate localization files, and generate the Firebase Data Connect SDK: ```bash @@ -42,7 +95,7 @@ This command will: **Note:** The Firebase Data Connect SDK files (`dataconnect_generated/`) are auto-generated and not committed to the repository. They will be regenerated automatically when you run `make mobile-install` or any mobile development commands. -### 3. Running the Apps +### 4. Running the Apps You can run the applications using Melos scripts or through the `Makefile`: First, find your device ID: diff --git a/apps/mobile/apps/client/android/.gitignore b/apps/mobile/apps/client/android/.gitignore index be3943c9..5064d8ff 100644 --- a/apps/mobile/apps/client/android/.gitignore +++ b/apps/mobile/apps/client/android/.gitignore @@ -7,8 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java .cxx/ -# Remember to never publicly share your keystore. +# Remember to never publicly share your keystore files. # See https://flutter.dev/to/reference-keystore -key.properties **/*.keystore **/*.jks diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 593af2c7..f169e26c 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Base64 +import java.util.Properties plugins { id("com.android.application") @@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach { } } +val keystoreProperties = Properties().apply { + val propertiesFile = rootProject.file("key.properties") + if (propertiesFile.exists()) { + load(propertiesFile.inputStream()) + } +} + android { namespace = "com.krowwithus.client" compileSdk = flutter.compileSdkVersion @@ -44,14 +52,32 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName - manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + } + + signingConfigs { + create("release") { + if (System.getenv()["CI"] == "true") { + // CodeMagic CI environment + storeFile = file(System.getenv()["CM_KEYSTORE_PATH_CLIENT"] ?: "") + storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_CLIENT"] + keyAlias = System.getenv()["CM_KEY_ALIAS_CLIENT"] + keyPassword = System.getenv()["CM_KEY_PASSWORD_CLIENT"] + } else { + // Local development environment + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/apps/mobile/apps/client/android/app/google-services.json b/apps/mobile/apps/client/android/app/google-services.json index fcd3c0e0..e7c91c27 100644 --- a/apps/mobile/apps/client/android/app/google-services.json +++ b/apps/mobile/apps/client/android/app/google-services.json @@ -86,11 +86,11 @@ }, "oauth_client": [ { - "client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com", + "client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.krowwithus.client", - "certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280" + "certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740" } }, { @@ -130,11 +130,11 @@ }, "oauth_client": [ { - "client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", + "client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.krowwithus.staff", - "certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" + "certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153" } }, { diff --git a/apps/mobile/apps/client/android/key.properties b/apps/mobile/apps/client/android/key.properties new file mode 100644 index 00000000..b07f333c --- /dev/null +++ b/apps/mobile/apps/client/android/key.properties @@ -0,0 +1,9 @@ +storePassword=krowwithus +keyPassword=krowwithus +keyAlias=krow_client_dev +storeFile=app/krow_with_us_client_dev.jks + +### +### Client +### SHA1: F5:49:1C:60:EC:20:EB:27:BB:3E:C5:81:35:2B:A6:53:05:3F:37:40 +### SHA256: 27:88:E4:EB:6C:BF:8E:25:66:37:76:B3:5D:DA:92:8A:CB:1A:6F:24:F3:38:9B:EA:DE:F0:25:62:FD:7A:7E:77 diff --git a/apps/mobile/apps/staff/android/.gitignore b/apps/mobile/apps/staff/android/.gitignore index be3943c9..5064d8ff 100644 --- a/apps/mobile/apps/staff/android/.gitignore +++ b/apps/mobile/apps/staff/android/.gitignore @@ -7,8 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java .cxx/ -# Remember to never publicly share your keystore. +# Remember to never publicly share your keystore files. # See https://flutter.dev/to/reference-keystore -key.properties **/*.keystore **/*.jks diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 24b1df50..135ca04e 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Base64 +import java.util.Properties plugins { id("com.android.application") @@ -20,6 +21,13 @@ dartDefinesString.split(",").forEach { } } +val keystoreProperties = Properties().apply { + val propertiesFile = rootProject.file("key.properties") + if (propertiesFile.exists()) { + load(propertiesFile.inputStream()) + } +} + android { namespace = "com.krowwithus.staff" compileSdk = flutter.compileSdkVersion @@ -44,14 +52,32 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName - manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" + } + + signingConfigs { + create("release") { + if (System.getenv()["CI"] == "true") { + // CodeMagic CI environment + storeFile = file(System.getenv()["CM_KEYSTORE_PATH_STAFF"] ?: "") + storePassword = System.getenv()["CM_KEYSTORE_PASSWORD_STAFF"] + keyAlias = System.getenv()["CM_KEY_ALIAS_STAFF"] + keyPassword = System.getenv()["CM_KEY_PASSWORD_STAFF"] + } else { + // Local development environment + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/apps/mobile/apps/staff/android/app/google-services.json b/apps/mobile/apps/staff/android/app/google-services.json index fcd3c0e0..8d5acf3a 100644 --- a/apps/mobile/apps/staff/android/app/google-services.json +++ b/apps/mobile/apps/staff/android/app/google-services.json @@ -86,11 +86,11 @@ }, "oauth_client": [ { - "client_id": "933560802882-fbqg2icq24bmci3f84evjrbth5huh87f.apps.googleusercontent.com", + "client_id": "933560802882-qbl6keingmd14fepn6qp76agdmbr84fg.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.krowwithus.client", - "certificate_hash": "c3efbe1642239c599c16ad04c7fac340902fe280" + "certificate_hash": "f5491c60ec20eb27bb3ec581352ba653053f3740" } }, { @@ -130,11 +130,11 @@ }, "oauth_client": [ { - "client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com", + "client_id": "933560802882-nh589kkndmur9hgibkgg5g8lhmo7mg3v.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.krowwithus.staff", - "certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d" + "certificate_hash": "a6ef7fe8ade313e69377b178544192d835b29153" } }, { @@ -167,4 +167,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/apps/mobile/apps/staff/android/key.properties b/apps/mobile/apps/staff/android/key.properties new file mode 100644 index 00000000..94fa9453 --- /dev/null +++ b/apps/mobile/apps/staff/android/key.properties @@ -0,0 +1,9 @@ +storePassword=krowwithus +keyPassword=krowwithus +keyAlias=krow_staff_dev +storeFile=app/krow_with_us_staff_dev.jks + +### +### Staff +### SHA1: A6:EF:7F:E8:AD:E3:13:E6:93:77:B1:78:54:41:92:D8:35:B2:91:53 +### SHA256: 26:B5:BD:1A:DE:18:92:1F:A3:7B:59:99:5E:4E:D0:BB:DF:93:D6:F6:01:16:04:55:0F:AA:57:55:C1:6B:7D:95 \ No newline at end of file diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index d21ac6ce..511e6f15 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -339,7 +339,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Future signOut() async { try { await _service.auth.signOut(); - dc.ClientSessionStore.instance.clear(); _service.clearCache(); } catch (e) { throw Exception('Error signing out: ${e.toString()}'); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index 4ce3382b..2dd39496 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -3,8 +3,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; + import '../blocs/tax_forms/tax_forms_cubit.dart'; import '../blocs/tax_forms/tax_forms_state.dart'; @@ -14,39 +14,10 @@ class TaxFormsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - backgroundColor: UiColors.primary, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.arrowLeft, color: UiColors.bgPopup), - onPressed: () => Modular.to.popSafe(), - ), - title: Text( - 'Tax Documents', - style: UiTypography.headline3m.textSecondary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(24), - child: Padding( - padding: const EdgeInsets.only( - left: UiConstants.space5, - right: UiConstants.space5, - bottom: UiConstants.space5, - ), - child: Row( - children: [ - Expanded( - child: Text( - 'Complete required forms to start working', - style: UiTypography.body3r.copyWith( - color: UiColors.primaryForeground.withValues(alpha: 0.8), - ), - ), - ), - ], - ), - ), - ), + appBar: const UiAppBar( + title: 'Tax Documents', + subtitle: 'Complete required forms to start working', + showBackButton: true, ), body: BlocProvider( create: (BuildContext context) { @@ -84,7 +55,7 @@ class TaxFormsPage extends StatelessWidget { vertical: UiConstants.space6, ), child: Column( - spacing: UiConstants.space6, + spacing: UiConstants.space4, children: [ _buildProgressOverview(state.forms), ...state.forms.map( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 65e61a9f..1f0b60f1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -351,14 +351,13 @@ class _FileTypesBanner extends StatelessWidget { vertical: UiConstants.space3, ), decoration: BoxDecoration( - color: UiColors.tagActive, + color: UiColors.primary.withAlpha(20), borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(UiIcons.info, size: 20, color: UiColors.primary), + const Icon(UiIcons.info, size: 20, color: UiColors.primary), const SizedBox(width: UiConstants.space3), Expanded( child: Text(message, style: UiTypography.body2r.textSecondary), diff --git a/codemagic.yaml b/codemagic.yaml index 1c6c846a..d853fbba 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -1,3 +1,7 @@ +# Note: key.properties files are now committed to the repository +# CodeMagic keystores are uploaded via Team Settings > Code signing identities > Android keystores +# The keystores are referenced in each workflow's environment section with custom variable names + # Reusable script for building the Flutter app client-app-android-apk-build-script: &client-app-android-apk-build-script name: 👷🤖 Build Client App APK (Android) @@ -170,6 +174,12 @@ workflows: cocoapods: default groups: - client_app_dev_credentials + android_signing: + - keystore: krow_client_dev + keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT + key_alias_environment_variable: CM_KEY_ALIAS_CLIENT + key_password_environment_variable: CM_KEY_PASSWORD_CLIENT vars: ENV: dev scripts: @@ -185,6 +195,12 @@ workflows: cocoapods: default groups: - client_app_staging_credentials + android_signing: + - keystore: krow_client_staging + keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT + key_alias_environment_variable: CM_KEY_ALIAS_CLIENT + key_password_environment_variable: CM_KEY_PASSWORD_CLIENT vars: ENV: staging scripts: @@ -197,6 +213,12 @@ workflows: environment: groups: - client_app_prod_credentials + android_signing: + - keystore: krow_client_prod + keystore_environment_variable: CM_KEYSTORE_PATH_CLIENT + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_CLIENT + key_alias_environment_variable: CM_KEY_ALIAS_CLIENT + key_password_environment_variable: CM_KEY_PASSWORD_CLIENT vars: ENV: prod scripts: @@ -254,6 +276,12 @@ workflows: cocoapods: default groups: - staff_app_dev_credentials + android_signing: + - keystore: krow_staff_dev + keystore_environment_variable: CM_KEYSTORE_PATH_STAFF + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF + key_alias_environment_variable: CM_KEY_ALIAS_STAFF + key_password_environment_variable: CM_KEY_PASSWORD_STAFF vars: ENV: dev scripts: @@ -269,6 +297,12 @@ workflows: cocoapods: default groups: - staff_app_staging_credentials + android_signing: + - keystore: krow_staff_staging + keystore_environment_variable: CM_KEYSTORE_PATH_STAFF + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF + key_alias_environment_variable: CM_KEY_ALIAS_STAFF + key_password_environment_variable: CM_KEY_PASSWORD_STAFF vars: ENV: staging scripts: @@ -284,6 +318,12 @@ workflows: cocoapods: default groups: - staff_app_prod_credentials + android_signing: + - keystore: krow_staff_prod + keystore_environment_variable: CM_KEYSTORE_PATH_STAFF + keystore_password_environment_variable: CM_KEYSTORE_PASSWORD_STAFF + key_alias_environment_variable: CM_KEY_ALIAS_STAFF + key_password_environment_variable: CM_KEY_PASSWORD_STAFF vars: ENV: prod scripts: From ce095924bc7a69f0ed295094ef8bbb695e2c81a0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 22:42:04 -0500 Subject: [PATCH 18/38] feat: Implement notice and file types banners for attire upload and enhance incomplete profile messaging --- .../lib/src/l10n/en.i18n.json | 2 +- .../lib/src/l10n/es.i18n.json | 2 +- .../design_system/lib/design_system.dart | 1 + .../lib/src/widgets/ui_notice_banner.dart | 81 ++++++++++++++++++ .../pages/attire_capture_page.dart | 35 +------- .../file_types_banner.dart | 36 ++++++++ .../widgets/attire_info_card.dart | 33 +------- .../widgets/tabs/find_shifts_tab.dart | 82 +++++-------------- 8 files changed, 145 insertions(+), 127 deletions(-) create mode 100644 apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/file_types_banner.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index e0544de4..52fbdc50 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1318,7 +1318,7 @@ }, "find_shifts": { "incomplete_profile_banner_title": "Your account isn't complete yet.", - "incomplete_profile_banner_message": "You won't be able to apply for shifts until your account is fully set up. Complete your account now to unlock shift applications and start getting matched with opportunities.", + "incomplete_profile_banner_message": "Complete your account now to unlock shift applications and start getting matched with opportunities.", "incomplete_profile_cta": "Complete your account now", "search_hint": "Search jobs, location...", "filter_all": "All Jobs", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 599bfa23..3e057580 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1313,7 +1313,7 @@ }, "find_shifts": { "incomplete_profile_banner_title": "Tu cuenta aún no está completa.", - "incomplete_profile_banner_message": "No podrás solicitar turnos hasta que tu cuenta esté completamente configurada. Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.", + "incomplete_profile_banner_message": "Completa tu cuenta ahora para desbloquear las solicitudes de turnos y empezar a recibir oportunidades.", "incomplete_profile_cta": "Completa tu cuenta ahora", "search_hint": "Buscar trabajos, ubicaci\u00f3n...", "filter_all": "Todos", diff --git a/apps/mobile/packages/design_system/lib/design_system.dart b/apps/mobile/packages/design_system/lib/design_system.dart index 2bfc01d4..d25f49f0 100644 --- a/apps/mobile/packages/design_system/lib/design_system.dart +++ b/apps/mobile/packages/design_system/lib/design_system.dart @@ -12,3 +12,4 @@ export 'src/widgets/ui_button.dart'; export 'src/widgets/ui_chip.dart'; export 'src/widgets/ui_loading_page.dart'; export 'src/widgets/ui_snackbar.dart'; +export 'src/widgets/ui_notice_banner.dart'; diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart new file mode 100644 index 00000000..445e8141 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -0,0 +1,81 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../ui_constants.dart'; + +/// A customizable notice banner widget for displaying informational messages. +/// +/// [UiNoticeBanner] displays a message with an optional icon and supports +/// custom styling through title and description text. +class UiNoticeBanner extends StatelessWidget { + /// Creates a [UiNoticeBanner]. + const UiNoticeBanner({ + super.key, + this.icon, + required this.title, + this.description, + this.backgroundColor, + this.borderRadius, + this.padding, + }); + + /// The icon to display on the left side. + /// Defaults to null. The icon will be rendered with primary color and 24pt size. + final IconData? icon; + + /// The title text to display. + final String title; + + /// Optional description text to display below the title. + final String? description; + + /// The background color of the banner. + /// Defaults to [UiColors.primary] with 8% opacity. + final Color? backgroundColor; + + /// The border radius of the banner. + /// Defaults to [UiConstants.radiusLg]. + final BorderRadius? borderRadius; + + /// The padding around the banner content. + /// Defaults to [UiConstants.space4] on all sides. + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding ?? const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08), + borderRadius: borderRadius ?? UiConstants.radiusLg, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Icon(icon, color: UiColors.primary, size: 24), + const SizedBox(width: UiConstants.space3), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.body2m.textPrimary, + ), + if (description != null) ...[ + const SizedBox(height: 2), + Text( + description!, + style: UiTypography.body2r.textSecondary, + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 1f0b60f1..243c2b65 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; +import '../widgets/attire_capture_page/file_types_banner.dart'; import '../widgets/attire_capture_page/footer_section.dart'; import '../widgets/attire_capture_page/image_preview_section.dart'; import '../widgets/attire_capture_page/info_section.dart'; @@ -286,7 +287,7 @@ class _AttireCapturePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( children: [ - _FileTypesBanner( + FileTypesBanner( message: t .staff_profile_attire .upload_file_types_banner, @@ -335,35 +336,3 @@ class _AttireCapturePageState extends State { ); } } - -/// Banner displaying accepted file types and size limit for attire upload. -class _FileTypesBanner extends StatelessWidget { - const _FileTypesBanner({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.primary.withAlpha(20), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(UiIcons.info, size: 20, color: UiColors.primary), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text(message, style: UiTypography.body2r.textSecondary), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/file_types_banner.dart new file mode 100644 index 00000000..4791f4d5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/file_types_banner.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner displaying accepted file types and size limit for attire upload. +class FileTypesBanner extends StatelessWidget { + /// Creates a [FileTypesBanner]. + const FileTypesBanner({super.key, required this.message}); + + /// The message to display in the banner. + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(20), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(UiIcons.info, size: 20, color: UiColors.primary), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text(message, style: UiTypography.body2r.textSecondary), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart index d7e12dc4..f1c5cd46 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart @@ -7,35 +7,10 @@ class AttireInfoCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.shirt, color: UiColors.primary, size: 24), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.staff_profile_attire.info_card.title, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: 2), - Text( - t.staff_profile_attire.info_card.description, - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ), - ], - ), + return UiNoticeBanner( + icon: UiIcons.shirt, + title: t.staff_profile_attire.info_card.title, + description: t.staff_profile_attire.info_card.description, ); } } 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 592e03a9..726bc560 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 @@ -1,14 +1,13 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:core_localization/core_localization.dart'; + import '../../blocs/shifts/shifts_bloc.dart'; import '../my_shift_card.dart'; import '../shared/empty_state_view.dart'; -import 'package:geolocator/geolocator.dart'; class FindShiftsTab extends StatefulWidget { final List availableJobs; @@ -316,11 +315,21 @@ class _FindShiftsTabState extends State { children: [ // Incomplete profile banner if (!widget.profileComplete) ...[ - _IncompleteProfileBanner( - title: context.t.staff_shifts.find_shifts.incomplete_profile_banner_title, - message: context.t.staff_shifts.find_shifts.incomplete_profile_banner_message, - ctaText: context.t.staff_shifts.find_shifts.incomplete_profile_cta, - onCtaPressed: () => Modular.to.toProfile(), + Container( + padding: const EdgeInsets.all(UiConstants.space4), + child: UiNoticeBanner( + icon: UiIcons.sparkles, + title: context + .t + .staff_shifts + .find_shifts + .incomplete_profile_banner_title, + description: context + .t + .staff_shifts + .find_shifts + .incomplete_profile_banner_message, + ), ), ], // Search and Filters @@ -462,9 +471,7 @@ class _FindShiftsTabState extends State { ? () { BlocProvider.of( context, - ).add( - AcceptShiftEvent(shift.id), - ); + ).add(AcceptShiftEvent(shift.id)); UiSnackbar.show( context, message: context @@ -488,54 +495,3 @@ class _FindShiftsTabState extends State { ); } } - -/// Banner shown when the worker's profile is incomplete. -class _IncompleteProfileBanner extends StatelessWidget { - const _IncompleteProfileBanner({ - required this.title, - required this.message, - required this.ctaText, - required this.onCtaPressed, - }); - - final String title; - final String message; - final String ctaText; - final VoidCallback onCtaPressed; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - margin: const EdgeInsets.all(UiConstants.space5), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.tagPending, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.textWarning.withValues(alpha: 0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: UiTypography.body2b.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - Text( - message, - style: UiTypography.body3r.textSecondary, - ), - const SizedBox(height: UiConstants.space4), - UiButton.primary( - text: ctaText, - onPressed: onCtaPressed, - size: UiButtonSize.small, - ), - ], - ), - ); - } -} From e05ca7c0452e4708325d017a271d7f08649b6f6c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 28 Feb 2026 22:46:55 -0500 Subject: [PATCH 19/38] feat: Refactor profile completion use cases and update related imports in HomeCubit and StaffHomeModule --- .../src/presentation/blocs/home_cubit.dart | 15 ++++----- .../staff/home/lib/src/staff_home_module.dart | 8 ++--- .../widgets/tabs/find_shifts_tab.dart | 33 +++++++++++-------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart index f77e1614..ac0e2408 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart @@ -1,11 +1,10 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; - -import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; +import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; part 'home_state.dart'; @@ -14,18 +13,18 @@ class HomeCubit extends Cubit with BlocErrorHandler { final GetHomeShifts _getHomeShifts; final HomeRepository _repository; - /// Use case that checks whether the staff member's personal info is complete. + /// Use case that checks whether the staff member's profile is complete. /// /// Used to determine whether profile-gated features (such as shift browsing) /// should be enabled on the home screen. - final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletion; + final GetProfileCompletionUseCase _getProfileCompletion; HomeCubit({ required HomeRepository repository, - required GetPersonalInfoCompletionUseCase getPersonalInfoCompletion, + required GetProfileCompletionUseCase getProfileCompletion, }) : _getHomeShifts = GetHomeShifts(repository), _repository = repository, - _getPersonalInfoCompletion = getPersonalInfoCompletion, + _getProfileCompletion = getProfileCompletion, super(const HomeState.initial()); Future loadShifts() async { @@ -37,7 +36,7 @@ class HomeCubit extends Cubit with BlocErrorHandler { // Fetch shifts, name, benefits and profile completion status concurrently final results = await Future.wait([ _getHomeShifts.call(), - _getPersonalInfoCompletion.call(), + _getProfileCompletion.call(), _repository.getBenefits(), _repository.getStaffName(), ]); diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 7945045f..74cc76c4 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -24,9 +24,9 @@ class StaffHomeModule extends Module { () => StaffConnectorRepositoryImpl(), ); - // Use case for checking personal info profile completion - i.addLazySingleton( - () => GetPersonalInfoCompletionUseCase( + // Use case for checking profile completion + i.addLazySingleton( + () => GetProfileCompletionUseCase( repository: i.get(), ), ); @@ -35,7 +35,7 @@ class StaffHomeModule extends Module { i.addSingleton( () => HomeCubit( repository: i.get(), - getPersonalInfoCompletion: i.get(), + getProfileCompletion: i.get(), ), ); } 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 726bc560..a9468691 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,7 +2,9 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../blocs/shifts/shifts_bloc.dart'; @@ -315,20 +317,23 @@ class _FindShiftsTabState extends State { children: [ // Incomplete profile banner if (!widget.profileComplete) ...[ - Container( - padding: const EdgeInsets.all(UiConstants.space4), - child: UiNoticeBanner( - icon: UiIcons.sparkles, - title: context - .t - .staff_shifts - .find_shifts - .incomplete_profile_banner_title, - description: context - .t - .staff_shifts - .find_shifts - .incomplete_profile_banner_message, + GestureDetector( + onTap: () => Modular.to.toProfile(), + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + child: UiNoticeBanner( + icon: UiIcons.sparkles, + title: context + .t + .staff_shifts + .find_shifts + .incomplete_profile_banner_title, + description: context + .t + .staff_shifts + .find_shifts + .incomplete_profile_banner_message, + ), ), ), ], From ea6b3fcc767258147bbf7be67e90e97fef41471f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 02:16:26 -0500 Subject: [PATCH 20/38] feat: Refactor emergency contact screen and info banner for improved UI consistency --- .../pages/emergency_contact_screen.dart | 21 +++++-------------- .../emergency_contact_info_banner.dart | 13 +++--------- .../pages/personal_info_page.dart | 20 +++--------------- .../blocs/shifts/shifts_bloc.dart | 2 +- 4 files changed, 12 insertions(+), 44 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart index ca97ecb4..ab377812 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart @@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; + import '../blocs/emergency_contact_bloc.dart'; import '../widgets/emergency_contact_add_button.dart'; import '../widgets/emergency_contact_form_item.dart'; @@ -22,22 +22,11 @@ class EmergencyContactScreen extends StatelessWidget { Widget build(BuildContext context) { Translations.of(context); // Force rebuild on locale change return Scaffold( - appBar: AppBar( - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.popSafe(), - ), - title: Text( - 'Emergency Contact', - style: UiTypography.title1m.textPrimary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), + appBar: UiAppBar( + title: 'Emergency Contact', + showBackButton: true, ), - body: BlocProvider( + body: BlocProvider( create: (context) => Modular.get(), child: BlocConsumer( listener: (context, state) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart index e8d26179..00ed24a7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart @@ -6,16 +6,9 @@ class EmergencyContactInfoBanner extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.accent.withValues(alpha: 0.2), - borderRadius: UiConstants.radiusLg, - ), - child: Text( - 'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.', - style: UiTypography.body2r.textPrimary, - ), + return UiNoticeBanner( + title: + 'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.', ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index 67776f05..239f5bce 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -47,23 +47,9 @@ class PersonalInfoPage extends StatelessWidget { } }, child: Scaffold( - backgroundColor: UiColors.background, - appBar: AppBar( - backgroundColor: UiColors.bgPopup, - elevation: 0, - leading: IconButton( - icon: const Icon( - UiIcons.chevronLeft, - color: UiColors.textSecondary, - ), - onPressed: () => Modular.to.popSafe(), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ), - title: Text(i18n.title, style: UiTypography.title1m.textPrimary), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), + appBar: UiAppBar( + title: i18n.title, + showBackButton: true, ), body: SafeArea( child: BlocBuilder( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 2d2a9f8c..fa398224 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -294,7 +294,7 @@ class ShiftsBloc extends Bloc return shifts.where((shift) { if (shift.date.isEmpty) return false; try { - final shiftDate = DateTime.parse(shift.date); + final shiftDate = DateTime.parse(shift.date).toLocal(); final dateOnly = DateTime( shiftDate.year, shiftDate.month, From 015f1fbc1babd3c65d6755d6e20550337d2afcd8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 03:06:28 -0500 Subject: [PATCH 21/38] feat: Refactor onboarding experience and personal info pages - Updated ExperiencePage to include subtitles in ExperienceSectionTitle. - Modified ExperienceSectionTitle widget to accept an optional subtitle parameter. - Refactored PersonalInfoPage to improve imports and structure. - Removed unused PersonalInfoContent and PersonalInfoForm widgets. - Introduced new widgets: EditableField, FieldLabel, ReadOnlyField, TappableRow, and LanguageSelector for better modularity. - Added AccountCard and SecurityNotice widgets for bank account section. - Enhanced SaveButton to utilize UiButton for consistency. --- .../design_system/lib/src/ui_typography.dart | 10 + .../presentation/pages/certificates_page.dart | 5 +- .../widgets/certificates_header.dart | 32 +- .../presentation/pages/bank_account_page.dart | 192 +++--------- .../presentation/widgets/account_card.dart | 97 ++++++ .../presentation/widgets/security_notice.dart | 20 ++ .../presentation/pages/experience_page.dart | 16 +- .../widgets/experience_section_title.dart | 26 +- .../pages/personal_info_page.dart | 6 +- .../widgets/personal_info_form.dart | 293 ------------------ .../personal_info_page/editable_field.dart | 63 ++++ .../personal_info_page/field_label.dart | 16 + .../personal_info_page/language_selector.dart | 54 ++++ .../personal_info_content.dart | 14 +- .../personal_info_form.dart | 99 ++++++ .../personal_info_page/read_only_field.dart | 28 ++ .../personal_info_page/tappable_row.dart | 75 +++++ .../src/presentation/widgets/save_button.dart | 46 +-- 18 files changed, 562 insertions(+), 530 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart delete mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/editable_field.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/field_label.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart rename apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/{ => personal_info_page}/personal_info_content.dart (88%) create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/read_only_field.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart 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 8e1ce9bb..fde6263a 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -264,6 +264,16 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Title Uppercase 2 Bold - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.7 (#121826) + /// Used for section headers and important labels. + static final TextStyle titleUppercase2b = _primaryBase.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + height: 1.5, + letterSpacing: 0.4, + color: UiColors.textPrimary, + ); + /// Title Uppercase 3 Medium - Font: Instrument Sans, Size: 12, Height: 1.5, Spacing: 1.5 (#121826) static final TextStyle titleUppercase3m = _primaryBase.copyWith( fontWeight: FontWeight.w500, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart index 2b0d95ee..21e2c4c7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart @@ -54,7 +54,10 @@ class CertificatesPage extends StatelessWidget { final List documents = state.certificates; return Scaffold( - backgroundColor: UiColors.background, // Matches 0xFFF8FAFC + appBar: UiAppBar( + title: t.staff_certificates.title, + showBackButton: true, + ), body: SingleChildScrollView( child: Column( children: [ diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart index d270b5f4..925f415c 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_header.dart @@ -1,8 +1,6 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:core_localization/core_localization.dart'; class CertificatesHeader extends StatelessWidget { const CertificatesHeader({ @@ -36,39 +34,13 @@ class CertificatesHeader extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - UiColors.primary, UiColors.primary.withValues(alpha: 0.8), + UiColors.primary.withValues(alpha: 0.5), ], ), ), child: Column( children: [ - Row( - children: [ - GestureDetector( - onTap: () => Modular.to.popSafe(), - child: Container( - width: UiConstants.space10, - height: UiConstants.space10, - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.chevronLeft, - color: UiColors.white, - size: UiConstants.iconMd, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Text( - t.staff_certificates.title, - style: UiTypography.headline3m.white, - ), - ], - ), - const SizedBox(height: UiConstants.space8), Row( children: [ SizedBox( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index ca23581e..3f814544 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -1,14 +1,16 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:design_system/design_system.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_state.dart'; +import '../widgets/account_card.dart'; import '../widgets/add_account_form.dart'; +import '../widgets/security_notice.dart'; class BankAccountPage extends StatelessWidget { const BankAccountPage({super.key}); @@ -26,19 +28,9 @@ class BankAccountPage extends StatelessWidget { final dynamic strings = t.staff.profile.bank_account_page; return Scaffold( - backgroundColor: UiColors.background, - appBar: AppBar( - backgroundColor: UiColors.background, // Was surface - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.arrowLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.popSafe(), - ), - title: Text(strings.title, style: UiTypography.headline3m.textPrimary), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), + appBar: UiAppBar( + title: strings.title, + showBackButton: true, ), body: BlocConsumer( bloc: cubit, @@ -88,18 +80,51 @@ class BankAccountPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSecurityNotice(strings), + SecurityNotice(strings: strings), const SizedBox(height: UiConstants.space6), - Text( - strings.linked_accounts, - style: UiTypography.headline4m.copyWith( - color: UiColors.textPrimary, + if (state.accounts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space10, + ), + child: Column( + children: [ + const Icon( + UiIcons.building, + size: 48, + color: UiColors.iconSecondary, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No accounts yet', + style: UiTypography.headline4m, + textAlign: TextAlign.center, + ), + Text( + 'Add your first bank account to get started', + style: UiTypography.body2m.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else ...[ + Text( + strings.linked_accounts, + style: UiTypography.headline4m.copyWith( + color: UiColors.textPrimary, + ), ), - ), - const SizedBox(height: UiConstants.space3), - ...state.accounts.map( - (StaffBankAccount a) => _buildAccountCard(a, strings), - ), // Added type + const SizedBox(height: UiConstants.space3), + ...state.accounts.map( + (StaffBankAccount account) => AccountCard( + account: account, + strings: strings, + ), + ), + ], // Add extra padding at bottom const SizedBox(height: UiConstants.space20), ], @@ -157,119 +182,4 @@ class BankAccountPage extends StatelessWidget { ), ); } - - Widget _buildSecurityNotice(dynamic strings) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.shield, color: UiColors.primary, size: 20), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - strings.secure_title, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space1 - 2), // 2px - Text( - strings.secure_subtitle, - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildAccountCard(StaffBankAccount account, dynamic strings) { - final bool isPrimary = account.isPrimary; - const Color primaryColor = UiColors.primary; - - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.bgPopup, // Was surface, using bgPopup (white) for card - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: isPrimary ? primaryColor : UiColors.border, - width: isPrimary ? 2 : 1, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: const Center( - child: Icon( - UiIcons.building, - color: primaryColor, - size: UiConstants.iconLg, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - account.bankName, - style: UiTypography.body2m.textPrimary, - ), - Text( - strings.account_ending( - last4: account.last4?.isNotEmpty == true - ? account.last4! - : '----', - ), - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ], - ), - if (isPrimary) - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: primaryColor.withValues(alpha: 0.15), - borderRadius: UiConstants.radiusFull, - ), - child: Row( - children: [ - const Icon( - UiIcons.check, - size: UiConstants.iconXs, - color: primaryColor, - ), - const SizedBox(width: UiConstants.space1), - Text(strings.primary, style: UiTypography.body3m.primary), - ], - ), - ), - ], - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart new file mode 100644 index 00000000..bf9356c9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/account_card.dart @@ -0,0 +1,97 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +class AccountCard extends StatelessWidget { + final StaffBankAccount account; + final dynamic strings; + + const AccountCard({ + super.key, + required this.account, + required this.strings, + }); + + @override + Widget build(BuildContext context) { + final bool isPrimary = account.isPrimary; + const Color primaryColor = UiColors.primary; + + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: isPrimary ? primaryColor : UiColors.border, + width: isPrimary ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: const Center( + child: Icon( + UiIcons.building, + color: primaryColor, + size: UiConstants.iconLg, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.bankName, + style: UiTypography.body2m.textPrimary, + ), + Text( + strings.account_ending( + last4: account.last4?.isNotEmpty == true + ? account.last4! + : '----', + ), + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ], + ), + if (isPrimary) + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.15), + borderRadius: UiConstants.radiusFull, + ), + child: Row( + children: [ + const Icon( + UiIcons.check, + size: UiConstants.iconXs, + color: primaryColor, + ), + const SizedBox(width: UiConstants.space1), + Text(strings.primary, style: UiTypography.body3m.primary), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart new file mode 100644 index 00000000..b0739b2f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/security_notice.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class SecurityNotice extends StatelessWidget { + final dynamic strings; + + const SecurityNotice({ + super.key, + required this.strings, + }); + + @override + Widget build(BuildContext context) { + return UiNoticeBanner( + icon: UiIcons.shield, + title: strings.secure_title, + description: strings.secure_subtitle, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart index 7b42e3d0..e33628af 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart @@ -116,10 +116,9 @@ class ExperiencePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ExperienceSectionTitle(title: i18n.industries_title), - Text( - i18n.industries_subtitle, - style: UiTypography.body2m.textSecondary, + ExperienceSectionTitle( + title: i18n.industries_title, + subtitle: i18n.industries_subtitle, ), const SizedBox(height: UiConstants.space3), Wrap( @@ -142,11 +141,10 @@ class ExperiencePage extends StatelessWidget { ) .toList(), ), - const SizedBox(height: UiConstants.space6), - ExperienceSectionTitle(title: i18n.skills_title), - Text( - i18n.skills_subtitle, - style: UiTypography.body2m.textSecondary, + const SizedBox(height: UiConstants.space10), + ExperienceSectionTitle( + title: i18n.skills_title, + subtitle: i18n.skills_subtitle, ), const SizedBox(height: UiConstants.space3), Wrap( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart index 7a588933..28cfc255 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart @@ -3,17 +3,31 @@ import 'package:flutter/material.dart'; class ExperienceSectionTitle extends StatelessWidget { final String title; - const ExperienceSectionTitle({super.key, required this.title}); + final String? subtitle; + const ExperienceSectionTitle({ + super.key, + required this.title, + this.subtitle, + }); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: UiConstants.space2), - child: Text( - title, - style: UiTypography.title2m.copyWith( - color: UiColors.textPrimary, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.title2m, + ), + if (subtitle != null) ...[ + Text( + subtitle!, + style: UiTypography.body2r.textSecondary, + ), + ], + ], ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index 239f5bce..a7cbf5cc 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart'; -import '../blocs/personal_info_bloc.dart'; -import '../blocs/personal_info_state.dart'; -import '../widgets/personal_info_content.dart'; /// The Personal Info page for staff onboarding. /// diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart deleted file mode 100644 index 5a50f6ef..00000000 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart +++ /dev/null @@ -1,293 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; - -/// A form widget containing all personal information fields. -/// -/// Includes read-only fields for full name, -/// and editable fields for email and phone. -/// The Preferred Locations row navigates to a dedicated Uber-style page. -/// Uses only design system tokens for colors, typography, and spacing. -class PersonalInfoForm extends StatelessWidget { - /// Creates a [PersonalInfoForm]. - const PersonalInfoForm({ - super.key, - required this.fullName, - required this.email, - required this.emailController, - required this.phoneController, - required this.currentLocations, - this.enabled = true, - }); - - /// The staff member's full name (read-only). - final String fullName; - - /// The staff member's email (read-only). - final String email; - - /// Controller for the email field. - final TextEditingController emailController; - - /// Controller for the phone number field. - final TextEditingController phoneController; - - /// Current preferred locations list to show in the summary row. - final List currentLocations; - - /// Whether the form fields are enabled for editing. - final bool enabled; - - @override - Widget build(BuildContext context) { - final TranslationsStaffOnboardingPersonalInfoEn i18n = - t.staff.onboarding.personal_info; - final String locationSummary = currentLocations.isEmpty - ? i18n.locations_summary_none - : currentLocations.join(', '); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldLabel(text: i18n.full_name_label), - const SizedBox(height: UiConstants.space2), - _ReadOnlyField(value: fullName), - const SizedBox(height: UiConstants.space4), - - _FieldLabel(text: i18n.email_label), - const SizedBox(height: UiConstants.space2), - _EditableField( - controller: emailController, - hint: i18n.email_label, - enabled: enabled, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], - ), - const SizedBox(height: UiConstants.space4), - - _FieldLabel(text: i18n.phone_label), - const SizedBox(height: UiConstants.space2), - _EditableField( - controller: phoneController, - hint: i18n.phone_hint, - enabled: enabled, - keyboardType: TextInputType.phone, - ), - const SizedBox(height: UiConstants.space4), - - _FieldLabel(text: i18n.locations_label), - const SizedBox(height: UiConstants.space2), - // Uber-style tappable row → navigates to PreferredLocationsPage - _TappableRow( - value: locationSummary, - hint: i18n.locations_hint, - icon: UiIcons.mapPin, - enabled: enabled, - onTap: enabled ? () => Modular.to.toPreferredLocations() : null, - ), - const SizedBox(height: UiConstants.space4), - - const _FieldLabel(text: 'Language'), - const SizedBox(height: UiConstants.space2), - _LanguageSelector(enabled: enabled), - ], - ); - } -} - -/// An Uber-style tappable row for navigating to a sub-page editor. -/// Displays the current value (or hint if empty) and a chevron arrow. -class _TappableRow extends StatelessWidget { - const _TappableRow({ - required this.value, - required this.hint, - required this.icon, - this.onTap, - this.enabled = true, - }); - - final String value; - final String hint; - final IconData icon; - final VoidCallback? onTap; - final bool enabled; - - @override - Widget build(BuildContext context) { - final bool hasValue = value.isNotEmpty; - return GestureDetector( - onTap: enabled ? onTap : null, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all( - color: enabled - ? UiColors.border - : UiColors.border.withValues(alpha: 0.5), - ), - ), - child: Row( - children: [ - Icon(icon, size: 18, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Text( - hasValue ? value : hint, - style: hasValue - ? UiTypography.body2r.textPrimary - : UiTypography.body2r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (enabled) - const Icon( - UiIcons.chevronRight, - size: 18, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ); - } -} - -/// A language selector widget that displays the current language and navigates to language selection page. -class _LanguageSelector extends StatelessWidget { - const _LanguageSelector({this.enabled = true}); - - final bool enabled; - - @override - Widget build(BuildContext context) { - final String currentLocale = Localizations.localeOf(context).languageCode; - final String languageName = currentLocale == 'es' ? 'Español' : 'English'; - - return GestureDetector( - onTap: enabled ? () => Modular.to.toLanguageSelection() : null, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: enabled ? UiColors.bgPopup : UiColors.bgSecondary, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all( - color: enabled - ? UiColors.border - : UiColors.border.withValues(alpha: 0.5), - ), - ), - child: Row( - children: [ - const Icon( - UiIcons.settings, - size: 18, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text(languageName, style: UiTypography.body2r.textPrimary), - ), - if (enabled) - const Icon( - UiIcons.chevronRight, - size: 18, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ); - } -} - -class _FieldLabel extends StatelessWidget { - const _FieldLabel({required this.text}); - final String text; - - @override - Widget build(BuildContext context) { - return Text(text, style: UiTypography.titleUppercase3m.textSecondary); - } -} - -class _ReadOnlyField extends StatelessWidget { - const _ReadOnlyField({required this.value}); - final String value; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all(color: UiColors.border), - ), - child: Text(value, style: UiTypography.body2r.textInactive), - ); - } -} - -class _EditableField extends StatelessWidget { - const _EditableField({ - required this.controller, - required this.hint, - this.enabled = true, - this.keyboardType, - this.autofillHints, - }); - final TextEditingController controller; - final String hint; - final bool enabled; - final TextInputType? keyboardType; - final Iterable? autofillHints; - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - enabled: enabled, - keyboardType: keyboardType, - autofillHints: autofillHints, - style: UiTypography.body2r.textPrimary, - decoration: InputDecoration( - hintText: hint, - hintStyle: UiTypography.body2r.textSecondary, - contentPadding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.primary), - ), - fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, - filled: true, - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/editable_field.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/editable_field.dart new file mode 100644 index 00000000..97010bc3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/editable_field.dart @@ -0,0 +1,63 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// An editable text field widget. +class EditableField extends StatelessWidget { + /// Creates an [EditableField]. + const EditableField({ + super.key, + required this.controller, + required this.hint, + this.enabled = true, + this.keyboardType, + this.autofillHints, + }); + + /// The text editing controller. + final TextEditingController controller; + + /// The hint text to display when empty. + final String hint; + + /// Whether the field is enabled for editing. + final bool enabled; + + /// The keyboard type for the field. + final TextInputType? keyboardType; + + /// Autofill hints for the field. + final Iterable? autofillHints; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + autofillHints: autofillHints, + style: UiTypography.body2r.textPrimary, + decoration: InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textSecondary, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.primary), + ), + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + filled: true, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/field_label.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/field_label.dart new file mode 100644 index 00000000..59c49bde --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/field_label.dart @@ -0,0 +1,16 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A label widget for form fields. +class FieldLabel extends StatelessWidget { + /// Creates a [FieldLabel]. + const FieldLabel({super.key, required this.text}); + + /// The label text to display. + final String text; + + @override + Widget build(BuildContext context) { + return Text(text, style: UiTypography.titleUppercase2b.textSecondary); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart new file mode 100644 index 00000000..30bdff3d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/field_label.dart'; + +/// A language selector widget that displays the current language and navigates to language selection page. +class LanguageSelector extends StatelessWidget { + /// Creates a [LanguageSelector]. + const LanguageSelector({super.key, this.enabled = true}); + + /// Whether the selector is enabled for interaction. + final bool enabled; + + @override + Widget build(BuildContext context) { + final String currentLocale = Localizations.localeOf(context).languageCode; + final String languageName = currentLocale == 'es' ? 'Español' : 'English'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space3, + children: [ + const FieldLabel(text: 'Language'), + + GestureDetector( + onTap: enabled ? () => Modular.to.toLanguageSelection() : null, + child: Row( + children: [ + const Icon( + UiIcons.settings, + size: 18, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + languageName, + style: UiTypography.body2r.textPrimary, + ), + ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart similarity index 88% rename from apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart rename to apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart index 944f5297..9481bac6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_content.dart @@ -3,14 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:krow_domain/krow_domain.dart'; - -import '../blocs/personal_info_bloc.dart'; -import '../blocs/personal_info_event.dart'; -import '../blocs/personal_info_state.dart'; -import 'profile_photo_widget.dart'; -import 'personal_info_form.dart'; -import 'save_button.dart'; - +import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart'; +import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_form.dart'; +import 'package:staff_profile_info/src/presentation/widgets/profile_photo_widget.dart'; +import 'package:staff_profile_info/src/presentation/widgets/save_button.dart'; /// Content widget that displays and manages the staff profile form. /// diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart new file mode 100644 index 00000000..38c774b7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/editable_field.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/field_label.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/language_selector.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/read_only_field.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/tappable_row.dart'; + +/// A form widget containing all personal information fields. +/// +/// Includes read-only fields for full name, +/// and editable fields for email and phone. +/// The Preferred Locations row navigates to a dedicated Uber-style page. +/// Uses only design system tokens for colors, typography, and spacing. +class PersonalInfoForm extends StatelessWidget { + /// Creates a [PersonalInfoForm]. + const PersonalInfoForm({ + super.key, + required this.fullName, + required this.email, + required this.emailController, + required this.phoneController, + required this.currentLocations, + this.enabled = true, + }); + + /// The staff member's full name (read-only). + final String fullName; + + /// The staff member's email (read-only). + final String email; + + /// Controller for the email field. + final TextEditingController emailController; + + /// Controller for the phone number field. + final TextEditingController phoneController; + + /// Current preferred locations list to show in the summary row. + final List currentLocations; + + /// Whether the form fields are enabled for editing. + final bool enabled; + + @override + Widget build(BuildContext context) { + final TranslationsStaffOnboardingPersonalInfoEn i18n = + t.staff.onboarding.personal_info; + final String locationSummary = currentLocations.isEmpty + ? i18n.locations_summary_none + : currentLocations.join(', '); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FieldLabel(text: i18n.full_name_label), + const SizedBox(height: UiConstants.space2), + ReadOnlyField(value: fullName), + const SizedBox(height: UiConstants.space4), + + FieldLabel(text: i18n.email_label), + const SizedBox(height: UiConstants.space2), + EditableField( + controller: emailController, + hint: i18n.email_label, + enabled: enabled, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + ), + const SizedBox(height: UiConstants.space4), + + FieldLabel(text: i18n.phone_label), + const SizedBox(height: UiConstants.space2), + EditableField( + controller: phoneController, + hint: i18n.phone_hint, + enabled: enabled, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: UiConstants.space4), + TappableRow( + value: locationSummary, + hint: i18n.locations_hint, + icon: UiIcons.mapPin, + enabled: enabled, + onTap: enabled ? () => Modular.to.toPreferredLocations() : null, + ), + const SizedBox(height: UiConstants.space6), + const Divider(), + const SizedBox(height: UiConstants.space6), + + LanguageSelector(enabled: enabled), + ], + ); + } +} \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/read_only_field.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/read_only_field.dart new file mode 100644 index 00000000..2bd956a0 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/read_only_field.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A read-only text field widget. +class ReadOnlyField extends StatelessWidget { + /// Creates a [ReadOnlyField]. + const ReadOnlyField({super.key, required this.value}); + + /// The value to display. + final String value; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Text(value, style: UiTypography.body2r.textInactive), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart new file mode 100644 index 00000000..6d92aa9e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/tappable_row.dart @@ -0,0 +1,75 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/field_label.dart'; + +/// An Uber-style tappable row for navigating to a sub-page editor. +/// Displays the current value (or hint if empty) and a chevron arrow. +class TappableRow extends StatelessWidget { + /// Creates a [TappableRow]. + const TappableRow({ + super.key, + required this.value, + required this.hint, + required this.icon, + this.onTap, + this.enabled = true, + }); + + /// The current value to display. + final String value; + + /// The hint text to display when value is empty. + final String hint; + + /// The icon to display on the left. + final IconData icon; + + /// Callback when the row is tapped. + final VoidCallback? onTap; + + /// Whether the row is enabled for tapping. + final bool enabled; + + @override + Widget build(BuildContext context) { + final bool hasValue = value.isNotEmpty; + final TranslationsStaffOnboardingPersonalInfoEn i18n = + t.staff.onboarding.personal_info; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space3, + children: [ + FieldLabel(text: i18n.locations_label), + GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + width: double.infinity, + child: Row( + children: [ + Icon(icon, size: 18, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + hasValue ? value : hint, + style: hasValue + ? UiTypography.body2r.textPrimary + : UiTypography.body2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart index ea03339b..e13c0681 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; /// A save button widget for the bottom of the personal info page. @@ -31,47 +31,15 @@ class SaveButton extends StatelessWidget { decoration: const BoxDecoration( color: UiColors.bgPopup, border: Border( - top: BorderSide(color: UiColors.border), + top: BorderSide(color: UiColors.border, width: 0.5), ), ), child: SafeArea( - child: SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - ), - elevation: 0, - ), - child: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - UiColors.bgPopup, - ), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(UiIcons.check, color: UiColors.bgPopup, size: 20), - const SizedBox(width: UiConstants.space2), - Text( - label, - style: UiTypography.body1m.copyWith( - color: UiColors.bgPopup, - ), - ), - ], - ), - ), + child: UiButton.primary( + fullWidth: true, + onPressed: onPressed, + text: label, + isLoading: isLoading, ), ), ); From 2c61baaaa953223cc328556c167f66f5da79645c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 03:22:48 -0500 Subject: [PATCH 22/38] feat: Add UiEmptyState widget and integrate it into BankAccountPage and WorkerHomePage for improved empty state handling --- .../design_system/lib/design_system.dart | 1 + .../design_system/lib/src/ui_typography.dart | 9 + .../lib/src/widgets/ui_empty_state.dart | 44 +++ .../presentation/pages/worker_home_page.dart | 325 ++++++++++-------- .../presentation/pages/bank_account_page.dart | 44 +-- 5 files changed, 240 insertions(+), 183 deletions(-) create mode 100644 apps/mobile/packages/design_system/lib/src/widgets/ui_empty_state.dart diff --git a/apps/mobile/packages/design_system/lib/design_system.dart b/apps/mobile/packages/design_system/lib/design_system.dart index d25f49f0..36c51fad 100644 --- a/apps/mobile/packages/design_system/lib/design_system.dart +++ b/apps/mobile/packages/design_system/lib/design_system.dart @@ -13,3 +13,4 @@ export 'src/widgets/ui_chip.dart'; export 'src/widgets/ui_loading_page.dart'; export 'src/widgets/ui_snackbar.dart'; export 'src/widgets/ui_notice_banner.dart'; +export 'src/widgets/ui_empty_state.dart'; 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 fde6263a..eb436569 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -131,6 +131,15 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Title 1 Bold - Font: Instrument Sans, Size: 16, Height: 1.5 (#121826) + /// Used for section headers and important labels. + static final TextStyle title1b = _primaryBase.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18, + height: 1.5, + color: UiColors.textPrimary, + ); + /// Title 2 Bold - Font: Instrument Sans, Size: 20, Height: 1.1 (#121826) static final TextStyle title2b = _primaryBase.copyWith( fontWeight: FontWeight.w600, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_empty_state.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_empty_state.dart new file mode 100644 index 00000000..d719db2f --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_empty_state.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class UiEmptyState extends StatelessWidget { + const UiEmptyState({ + super.key, + required this.icon, + required this.title, + required this.description, + this.iconColor, + }); + + final IconData icon; + final String title; + final String description; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: iconColor ?? UiColors.iconDisabled), + const SizedBox(height: UiConstants.space5), + Text( + title, + style: UiTypography.title1b.textDescription, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: Text( + description, + style: UiTypography.body2m.textDescription, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 409dae51..1ba3ae29 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -61,12 +61,41 @@ class WorkerHomePage extends StatelessWidget { horizontal: UiConstants.space4, vertical: UiConstants.space4, ), - child: Column( - children: [ - BlocBuilder( - builder: (context, state) { - if (state.isProfileComplete) return const SizedBox(); - return PlaceholderBanner( + child: BlocBuilder( + buildWhen: (previous, current) => + previous.isProfileComplete != current.isProfileComplete, + builder: (context, state) { + if (!state.isProfileComplete) { + return SizedBox( + height: MediaQuery.of(context).size.height - + 300, + child: Column( + children: [ + PlaceholderBanner( + title: bannersI18n.complete_profile_title, + subtitle: bannersI18n.complete_profile_subtitle, + bg: UiColors.primaryInverse, + accent: UiColors.primary, + onTap: () { + Modular.to.toProfile(); + }, + ), + const SizedBox(height: UiConstants.space10), + Expanded( + child: UiEmptyState( + icon: UiIcons.users, + title: 'Complete Your Profile', + description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.', + ), + ), + ], + ), + ); + } + + return Column( + children: [ + PlaceholderBanner( title: bannersI18n.complete_profile_title, subtitle: bannersI18n.complete_profile_subtitle, bg: UiColors.primaryInverse, @@ -74,156 +103,156 @@ class WorkerHomePage extends StatelessWidget { onTap: () { Modular.to.toProfile(); }, - ); - }, - ), - - const SizedBox(height: UiConstants.space6), - - // Quick Actions - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: QuickActionItem( - icon: UiIcons.search, - label: quickI18n.find_shifts, - onTap: () => Modular.to.toShifts(), - ), ), - Expanded( - child: QuickActionItem( - icon: UiIcons.calendar, - label: quickI18n.availability, - onTap: () => Modular.to.toAvailability(), - ), - ), - Expanded( - child: QuickActionItem( - icon: UiIcons.dollar, - label: quickI18n.earnings, - onTap: () => Modular.to.toPayments(), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - // Today's Shifts - BlocBuilder( - builder: (context, state) { - final shifts = state.todayShifts; - return Column( + const SizedBox(height: UiConstants.space6), + + // Quick Actions + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SectionHeader( - title: sectionsI18n.todays_shift, - action: shifts.isNotEmpty - ? sectionsI18n.scheduled_count( - count: shifts.length, - ) - : null, + Expanded( + child: QuickActionItem( + icon: UiIcons.search, + label: quickI18n.find_shifts, + onTap: () => Modular.to.toShifts(), + ), ), - if (state.status == HomeStatus.loading) - const Center( - child: SizedBox( - height: UiConstants.space10, - width: UiConstants.space10, - child: CircularProgressIndicator( - color: UiColors.primary, + Expanded( + child: QuickActionItem( + icon: UiIcons.calendar, + label: quickI18n.availability, + onTap: () => Modular.to.toAvailability(), + ), + ), + Expanded( + child: QuickActionItem( + icon: UiIcons.dollar, + label: quickI18n.earnings, + onTap: () => Modular.to.toPayments(), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space6), + + // Today's Shifts + BlocBuilder( + builder: (context, state) { + final shifts = state.todayShifts; + return Column( + children: [ + SectionHeader( + title: sectionsI18n.todays_shift, + action: shifts.isNotEmpty + ? sectionsI18n.scheduled_count( + count: shifts.length, + ) + : null, + ), + if (state.status == HomeStatus.loading) + const Center( + child: SizedBox( + height: UiConstants.space10, + width: UiConstants.space10, + child: CircularProgressIndicator( + color: UiColors.primary, + ), + ), + ) + else if (shifts.isEmpty) + EmptyStateWidget( + message: emptyI18n.no_shifts_today, + actionLink: emptyI18n.find_shifts_cta, + onAction: () => + Modular.to.toShifts(initialTab: 'find'), + ) + else + Column( + children: shifts + .map( + (shift) => ShiftCard( + shift: shift, + compact: true, + ), + ) + .toList(), + ), + ], + ); + }, + ), + const SizedBox(height: UiConstants.space3), + + // Tomorrow's Shifts + BlocBuilder( + builder: (context, state) { + final shifts = state.tomorrowShifts; + return Column( + children: [ + SectionHeader(title: sectionsI18n.tomorrow), + if (shifts.isEmpty) + EmptyStateWidget( + message: emptyI18n.no_shifts_tomorrow, + ) + else + Column( + children: shifts + .map( + (shift) => ShiftCard( + shift: shift, + compact: true, + ), + ) + .toList(), + ), + ], + ); + }, + ), + const SizedBox(height: UiConstants.space3), + + // Recommended Shifts + SectionHeader(title: sectionsI18n.recommended_for_you), + BlocBuilder( + builder: (context, state) { + if (state.recommendedShifts.isEmpty) { + return EmptyStateWidget( + message: emptyI18n.no_recommended_shifts, + ); + } + return SizedBox( + height: 160, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: state.recommendedShifts.length, + clipBehavior: Clip.none, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only( + right: UiConstants.space3, + ), + child: RecommendedShiftCard( + shift: state.recommendedShifts[index], ), ), - ) - else if (shifts.isEmpty) - EmptyStateWidget( - message: emptyI18n.no_shifts_today, - actionLink: emptyI18n.find_shifts_cta, - onAction: () => - Modular.to.toShifts(initialTab: 'find'), - ) - else - Column( - children: shifts - .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), - ) - .toList(), ), - ], - ); - }, - ), - const SizedBox(height: UiConstants.space3), + ); + }, + ), + const SizedBox(height: UiConstants.space6), - // Tomorrow's Shifts - BlocBuilder( - builder: (context, state) { - final shifts = state.tomorrowShifts; - return Column( - children: [ - SectionHeader(title: sectionsI18n.tomorrow), - if (shifts.isEmpty) - EmptyStateWidget( - message: emptyI18n.no_shifts_tomorrow, - ) - else - Column( - children: shifts - .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), - ) - .toList(), - ), - ], - ); - }, - ), - const SizedBox(height: UiConstants.space3), - - // Recommended Shifts - SectionHeader(title: sectionsI18n.recommended_for_you), - BlocBuilder( - builder: (context, state) { - if (state.recommendedShifts.isEmpty) { - return EmptyStateWidget( - message: emptyI18n.no_recommended_shifts, - ); - } - return SizedBox( - height: 160, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: state.recommendedShifts.length, - clipBehavior: Clip.none, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only( - right: UiConstants.space3, - ), - child: RecommendedShiftCard( - shift: state.recommendedShifts[index], - ), - ), - ), - ); - }, - ), - const SizedBox(height: UiConstants.space6), - - // Benefits - BlocBuilder( - buildWhen: (previous, current) => - previous.benefits != current.benefits, - builder: (context, state) { - return BenefitsWidget(benefits: state.benefits); - }, - ), - const SizedBox(height: UiConstants.space6), - ], + // Benefits + BlocBuilder( + buildWhen: (previous, current) => + previous.benefits != current.benefits, + builder: (context, state) { + return BenefitsWidget(benefits: state.benefits); + }, + ), + const SizedBox(height: UiConstants.space6), + ], + ); + }, ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 3f814544..b81bae6f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -28,10 +28,7 @@ class BankAccountPage extends StatelessWidget { final dynamic strings = t.staff.profile.bank_account_page; return Scaffold( - appBar: UiAppBar( - title: strings.title, - showBackButton: true, - ), + appBar: UiAppBar(title: strings.title, showBackButton: true), body: BlocConsumer( bloc: cubit, listener: (BuildContext context, BankAccountState state) { @@ -81,34 +78,13 @@ class BankAccountPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SecurityNotice(strings: strings), - const SizedBox(height: UiConstants.space6), + const SizedBox(height: UiConstants.space32), if (state.accounts.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space10, - ), - child: Column( - children: [ - const Icon( - UiIcons.building, - size: 48, - color: UiColors.iconSecondary, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'No accounts yet', - style: UiTypography.headline4m, - textAlign: TextAlign.center, - ), - Text( - 'Add your first bank account to get started', - style: UiTypography.body2m.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), + const UiEmptyState( + icon: UiIcons.building, + title: 'No accounts yet', + description: + 'Add your first bank account to get started', ) else ...[ Text( @@ -119,10 +95,8 @@ class BankAccountPage extends StatelessWidget { ), const SizedBox(height: UiConstants.space3), ...state.accounts.map( - (StaffBankAccount account) => AccountCard( - account: account, - strings: strings, - ), + (StaffBankAccount account) => + AccountCard(account: account, strings: strings), ), ], // Add extra padding at bottom From 1e1dc39e20d5e2451cdb64667973fb567cb83de8 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 12:02:43 -0500 Subject: [PATCH 23/38] feat: Implement preferred locations feature with search and display components --- .../blocs/personal_info_bloc.dart | 10 +- .../pages/preferred_locations_page.dart | 355 ++---------------- .../personal_info_form.dart | 4 +- .../empty_locations_state.dart | 38 ++ .../location_chip.dart | 95 +++++ .../locations_list.dart | 40 ++ .../places_search_field.dart | 143 +++++++ 7 files changed, 364 insertions(+), 321 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/empty_locations_state.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/location_chip.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/locations_list.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/places_search_field.dart diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart index 1f3f564f..52b942b3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart @@ -56,9 +56,9 @@ class PersonalInfoBloc extends Bloc 'email': staff.email, 'phone': staff.phone, 'preferredLocations': - staff.address != null - ? [staff.address!] - : [], // TODO: Map correctly when Staff entity supports list + staff.preferredLocations != null + ? List.from(staff.preferredLocations!) + : [], 'avatar': staff.avatar, }; @@ -111,8 +111,8 @@ class PersonalInfoBloc extends Bloc 'email': updatedStaff.email, 'phone': updatedStaff.phone, 'preferredLocations': - updatedStaff.address != null - ? [updatedStaff.address!] + updatedStaff.preferredLocations != null + ? List.from(updatedStaff.preferredLocations!) : [], 'avatar': updatedStaff.avatar, }; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart index 32629cd0..29024f42 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/preferred_locations_page.dart @@ -4,13 +4,15 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:google_places_flutter/google_places_flutter.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_core/core.dart'; import '../blocs/personal_info_bloc.dart'; import '../blocs/personal_info_event.dart'; import '../blocs/personal_info_state.dart'; +import '../widgets/preferred_locations_page/places_search_field.dart'; +import '../widgets/preferred_locations_page/locations_list.dart'; +import '../widgets/preferred_locations_page/empty_locations_state.dart'; /// The maximum number of preferred locations a staff member can add. const int _kMaxLocations = 5; @@ -80,7 +82,6 @@ class _PreferredLocationsPageState extends State { message: i18n.preferred_locations.save_success, type: UiSnackbarType.success, ); - Navigator.of(context).pop(); } else if (state.status == PersonalInfoStatus.error) { UiSnackbar.show( context, @@ -98,25 +99,14 @@ class _PreferredLocationsPageState extends State { return Scaffold( backgroundColor: UiColors.background, - appBar: AppBar( - backgroundColor: UiColors.bgPopup, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Navigator.of(context).pop(), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ), - title: Text( - i18n.preferred_locations.title, - style: UiTypography.title1m.textPrimary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), + appBar: UiAppBar( + title: i18n.preferred_locations.title, + showBackButton: true, ), - body: SafeArea( - child: Column( + body: Stack( + children: [ + SafeArea( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Description @@ -138,7 +128,7 @@ class _PreferredLocationsPageState extends State { padding: const EdgeInsets.symmetric( horizontal: UiConstants.space5, ), - child: _PlacesSearchField( + child: PlacesSearchField( controller: _searchController, focusNode: _searchFocusNode, hint: i18n.preferred_locations.search_hint, @@ -187,11 +177,11 @@ class _PreferredLocationsPageState extends State { const SizedBox(height: UiConstants.space3), - // ── Locations list / empty state + // Locations list / empty state Expanded( child: locations.isEmpty - ? _EmptyLocationsState(message: i18n.preferred_locations.empty_state) - : _LocationsList( + ? EmptyLocationsState(message: i18n.preferred_locations.empty_state) + : LocationsList( locations: locations, isSaving: isSaving, removeTooltip: i18n.preferred_locations.remove_tooltip, @@ -199,19 +189,42 @@ class _PreferredLocationsPageState extends State { ), ), - // ── Save button + // Save button Padding( padding: const EdgeInsets.all(UiConstants.space5), child: UiButton.primary( - text: i18n.preferred_locations.save_button, + text: isSaving ? null : i18n.preferred_locations.save_button, fullWidth: true, onPressed: isSaving ? null : () => _save(context, bloc, state), + child: isSaving + ? const SizedBox( + height: UiConstants.iconMd, + width: UiConstants.iconMd, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + UiColors.white, + ), + ), + ) + : null, ), ), ], ), ), - ); + if (isSaving) + Container( + color: UiColors.black.withValues(alpha: 0.3), + child: const Center( + child: CircularProgressIndicator( + color: UiColors.primary, + ), + ), + ), + ], + ), + ); }, ), ); @@ -225,291 +238,3 @@ class _PreferredLocationsPageState extends State { } } -// ───────────────────────────────────────────────────────────────────────────── -// Subwidgets -// ───────────────────────────────────────────────────────────────────────────── - -/// Google Places autocomplete search field, locked to US results. -class _PlacesSearchField extends StatelessWidget { - const _PlacesSearchField({ - required this.controller, - required this.focusNode, - required this.hint, - required this.onSelected, - this.enabled = true, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final String hint; - final bool enabled; - final void Function(Prediction) onSelected; - - @override - Widget build(BuildContext context) { - return GooglePlaceAutoCompleteTextField( - textEditingController: controller, - focusNode: focusNode, - googleAPIKey: AppConfig.googleMapsApiKey, - debounceTime: 400, - countries: const ['us'], - isLatLngRequired: false, - getPlaceDetailWithLatLng: onSelected, - itemClick: (Prediction prediction) { - controller.text = prediction.description ?? ''; - controller.selection = TextSelection.fromPosition( - TextPosition(offset: controller.text.length), - ); - onSelected(prediction); - }, - inputDecoration: InputDecoration( - hintText: hint, - hintStyle: UiTypography.body2r.textSecondary, - prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20), - suffixIcon: controller.text.isNotEmpty - ? IconButton( - icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary), - onPressed: controller.clear, - ) - : null, - contentPadding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space3, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: const BorderSide(color: UiColors.primary, width: 1.5), - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), - ), - fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, - filled: true, - ), - textStyle: UiTypography.body2r.textPrimary, - itemBuilder: (BuildContext context, int index, Prediction prediction) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space2, - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(UiConstants.space2), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(4.0), - ), - child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _mainText(prediction.description ?? ''), - style: UiTypography.body2m.textPrimary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (_subText(prediction.description ?? '').isNotEmpty) - Text( - _subText(prediction.description ?? ''), - style: UiTypography.footnote1r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ); - }, - ); - } - - /// Extracts text before first comma as the primary line. - String _mainText(String description) { - final int commaIndex = description.indexOf(','); - return commaIndex > 0 ? description.substring(0, commaIndex) : description; - } - - /// Extracts text after first comma as the secondary line. - String _subText(String description) { - final int commaIndex = description.indexOf(','); - return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : ''; - } -} - -/// The scrollable list of location chips. -class _LocationsList extends StatelessWidget { - const _LocationsList({ - required this.locations, - required this.isSaving, - required this.removeTooltip, - required this.onRemove, - }); - - final List locations; - final bool isSaving; - final String removeTooltip; - final void Function(String) onRemove; - - @override - Widget build(BuildContext context) { - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), - itemCount: locations.length, - separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2), - itemBuilder: (BuildContext context, int index) { - final String location = locations[index]; - return _LocationChip( - label: location, - index: index + 1, - total: locations.length, - isSaving: isSaving, - removeTooltip: removeTooltip, - onRemove: () => onRemove(location), - ); - }, - ); - } -} - -/// A single location row with pin icon, label, and remove button. -class _LocationChip extends StatelessWidget { - const _LocationChip({ - required this.label, - required this.index, - required this.total, - required this.isSaving, - required this.removeTooltip, - required this.onRemove, - }); - - final String label; - final int index; - final int total; - final bool isSaving; - final String removeTooltip; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - // Index badge - Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Text( - '$index', - style: UiTypography.footnote1m.copyWith(color: UiColors.primary), - ), - ), - const SizedBox(width: UiConstants.space3), - - // Pin icon - const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space2), - - // Location text - Expanded( - child: Text( - label, - style: UiTypography.body2m.textPrimary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - - // Remove button - if (!isSaving) - Tooltip( - message: removeTooltip, - child: GestureDetector( - onTap: onRemove, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.all(UiConstants.space1), - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: UiColors.bgSecondary, - shape: BoxShape.circle, - ), - child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary), - ), - ), - ), - ), - ], - ), - ); - } -} - -/// Shows when no locations have been added yet. -class _EmptyLocationsState extends StatelessWidget { - const _EmptyLocationsState({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.08), - shape: BoxShape.circle, - ), - child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary), - ), - const SizedBox(height: UiConstants.space4), - Text( - message, - textAlign: TextAlign.center, - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ), - ); - } -} - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart index 38c774b7..cd0ad51d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/personal_info_form.dart @@ -80,7 +80,9 @@ class PersonalInfoForm extends StatelessWidget { enabled: enabled, keyboardType: TextInputType.phone, ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space6), + const Divider(), + const SizedBox(height: UiConstants.space6), TappableRow( value: locationSummary, hint: i18n.locations_hint, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/empty_locations_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/empty_locations_state.dart new file mode 100644 index 00000000..c4dced31 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/empty_locations_state.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shows when no locations have been added yet. +class EmptyLocationsState extends StatelessWidget { + const EmptyLocationsState({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary), + ), + const SizedBox(height: UiConstants.space4), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/location_chip.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/location_chip.dart new file mode 100644 index 00000000..673f49c6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/location_chip.dart @@ -0,0 +1,95 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A single location row with pin icon, label, and remove button. +class LocationChip extends StatelessWidget { + const LocationChip({ + super.key, + required this.label, + required this.index, + required this.total, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final String label; + final int index; + final int total; + final bool isSaving; + final String removeTooltip; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + // Index badge + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Text( + '$index', + style: UiTypography.footnote1m.copyWith(color: UiColors.primary), + ), + ), + const SizedBox(width: UiConstants.space3), + + // Pin icon + const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space2), + + // Location text + Expanded( + child: Text( + label, + style: UiTypography.body2m.textPrimary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + // Remove button + if (!isSaving) + Tooltip( + message: removeTooltip, + child: GestureDetector( + onTap: onRemove, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space1), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: UiColors.bgSecondary, + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.close, + size: 14, + color: UiColors.iconSecondary, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/locations_list.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/locations_list.dart new file mode 100644 index 00000000..2f888d35 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/locations_list.dart @@ -0,0 +1,40 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'location_chip.dart'; + +/// The scrollable list of location chips. +class LocationsList extends StatelessWidget { + const LocationsList({ + super.key, + required this.locations, + required this.isSaving, + required this.removeTooltip, + required this.onRemove, + }); + + final List locations; + final bool isSaving; + final String removeTooltip; + final void Function(String) onRemove; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + itemCount: locations.length, + separatorBuilder: (_, _) => const SizedBox(height: UiConstants.space2), + itemBuilder: (BuildContext context, int index) { + final String location = locations[index]; + return LocationChip( + label: location, + index: index + 1, + total: locations.length, + isSaving: isSaving, + removeTooltip: removeTooltip, + onRemove: () => onRemove(location), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/places_search_field.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/places_search_field.dart new file mode 100644 index 00000000..bbe7fe5e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/preferred_locations_page/places_search_field.dart @@ -0,0 +1,143 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:google_places_flutter/google_places_flutter.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_core/core.dart'; + +/// Google Places autocomplete search field, locked to US results. +class PlacesSearchField extends StatelessWidget { + const PlacesSearchField({ + super.key, + required this.controller, + required this.focusNode, + required this.hint, + required this.onSelected, + this.enabled = true, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final String hint; + final bool enabled; + final void Function(Prediction) onSelected; + + /// Extracts text before first comma as the primary line. + String _mainText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(0, commaIndex) : description; + } + + /// Extracts text after first comma as the secondary line. + String _subText(String description) { + final int commaIndex = description.indexOf(','); + return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : ''; + } + + @override + Widget build(BuildContext context) { + return GooglePlaceAutoCompleteTextField( + textEditingController: controller, + focusNode: focusNode, + googleAPIKey: AppConfig.googleMapsApiKey, + debounceTime: 400, + countries: const ['us'], + isLatLngRequired: false, + getPlaceDetailWithLatLng: onSelected, + itemClick: (Prediction prediction) { + controller.text = prediction.description ?? ''; + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + onSelected(prediction); + }, + inputDecoration: InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textSecondary, + prefixIcon: const Icon( + UiIcons.search, + color: UiColors.iconSecondary, + size: 20, + ), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon( + UiIcons.close, + size: 18, + color: UiColors.iconSecondary, + ), + onPressed: controller.clear, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: const BorderSide(color: UiColors.primary, width: 1.5), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), + ), + fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary, + filled: true, + ), + textStyle: UiTypography.body2r.textPrimary, + itemBuilder: (BuildContext context, int index, Prediction prediction) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space2, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4.0), + ), + child: const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.primary, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _mainText(prediction.description ?? ''), + style: UiTypography.body2m.textPrimary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_subText(prediction.description ?? '').isNotEmpty) + Text( + _subText(prediction.description ?? ''), + style: UiTypography.footnote1r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} From b5001edf0656c83c9a6e7edaa426f06db0ea0b12 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 12:11:41 -0500 Subject: [PATCH 24/38] feat: Enhance locale management by retrieving saved language code and updating language selection UI --- .../src/data/repositories_impl/locale_repository_impl.dart | 7 +++++++ .../src/presentation/pages/language_selection_page.dart | 4 ---- .../widgets/personal_info_page/language_selector.dart | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart b/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart index 086cb954..861f579f 100644 --- a/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart +++ b/apps/mobile/packages/core_localization/lib/src/data/repositories_impl/locale_repository_impl.dart @@ -21,6 +21,13 @@ class LocaleRepositoryImpl implements LocaleRepositoryInterface { @override Future getSavedLocale() async { + final String? savedLanguageCode = await localDataSource.getLanguageCode(); + if (savedLanguageCode != null) { + final Locale savedLocale = Locale(savedLanguageCode); + if (getSupportedLocales().contains(savedLocale)) { + return savedLocale; + } + } return getDefaultLocale(); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart index ef4213d7..3a3e9deb 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -40,10 +40,6 @@ class LanguageSelectionPage extends StatelessWidget { appBar: UiAppBar( title: t.settings.change_language, showBackButton: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), ), body: SafeArea( child: BlocBuilder( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart index 30bdff3d..34c1ccd4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart @@ -20,7 +20,7 @@ class LanguageSelector extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space3, - children: [ + children: [ const FieldLabel(text: 'Language'), GestureDetector( From 315e8f9598a84d131c0cc3507a7930c79ae557cb Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 12:33:42 -0500 Subject: [PATCH 25/38] feat: Implement rapid order creation with voice and text input in client mobile app --- apps/mobile/apps/client/lib/main.dart | 7 +- apps/mobile/apps/staff/lib/main.dart | 7 +- .../design_system/lib/src/ui_icons.dart | 3 + .../personal_info_page/language_selector.dart | 74 +++++++++++-------- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 301ade92..f696a7c3 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -102,8 +102,10 @@ class AppWidget extends StatelessWidget { >( builder: (BuildContext context, core_localization.LocaleState state) { - return core_localization.TranslationProvider( - child: MaterialApp.router( + return KeyedSubtree( + key: ValueKey(state.locale), + child: core_localization.TranslationProvider( + child: MaterialApp.router( debugShowCheckedModeBanner: false, title: "KROW Client", theme: UiTheme.light, @@ -117,6 +119,7 @@ class AppWidget extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], ), + ), ); }, ), diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 5557a971..a50744c9 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -79,8 +79,10 @@ class AppWidget extends StatelessWidget { >( builder: (BuildContext context, core_localization.LocaleState state) { - return core_localization.TranslationProvider( - child: MaterialApp.router( + return KeyedSubtree( + key: ValueKey(state.locale), + child: core_localization.TranslationProvider( + child: MaterialApp.router( title: "KROW Staff", theme: UiTheme.light, routerConfig: Modular.routerConfig, @@ -93,6 +95,7 @@ class AppWidget extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], ), + ), ); }, ), 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 f9d97f3e..ddf7068d 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -288,4 +288,7 @@ class UiIcons { /// Microphone icon static const IconData microphone = _IconLib.mic; + + /// Language icon + static const IconData language = _IconLib.languages; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart index 34c1ccd4..63837b3d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_page/language_selector.dart @@ -1,5 +1,7 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/field_label.dart'; @@ -14,41 +16,49 @@ class LanguageSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final String currentLocale = Localizations.localeOf(context).languageCode; - final String languageName = currentLocale == 'es' ? 'Español' : 'English'; + return BlocBuilder( + bloc: Modular.get(), + buildWhen: (LocaleState previous, LocaleState current) => + previous.locale != current.locale, + builder: (BuildContext context, LocaleState state) { + final String currentLocale = state.locale.languageCode; + final String languageName = + currentLocale == 'es' ? 'Español' : 'English'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space3, - children: [ - const FieldLabel(text: 'Language'), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space3, + children: [ + const FieldLabel(text: 'Language'), - GestureDetector( - onTap: enabled ? () => Modular.to.toLanguageSelection() : null, - child: Row( - children: [ - const Icon( - UiIcons.settings, - size: 18, - color: UiColors.iconSecondary, + GestureDetector( + onTap: enabled ? () => Modular.to.toLanguageSelection() : null, + child: Row( + children: [ + const Icon( + UiIcons.language, + size: 18, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + languageName, + style: UiTypography.body2r.textPrimary, + ), + ), + if (enabled) + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text( - languageName, - style: UiTypography.body2r.textPrimary, - ), - ), - if (enabled) - const Icon( - UiIcons.chevronRight, - size: 16, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ], + ), + ], + ); + }, ); } } From 89f078054d879173d313c079848978d1e1f63989 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 12:43:07 -0500 Subject: [PATCH 26/38] feat: Ensure locale synchronization by reloading from persistent storage after locale change --- .../packages/core_localization/lib/src/bloc/locale_bloc.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/packages/core_localization/lib/src/bloc/locale_bloc.dart b/apps/mobile/packages/core_localization/lib/src/bloc/locale_bloc.dart index 59065746..5a63ee31 100644 --- a/apps/mobile/packages/core_localization/lib/src/bloc/locale_bloc.dart +++ b/apps/mobile/packages/core_localization/lib/src/bloc/locale_bloc.dart @@ -57,6 +57,9 @@ class LocaleBloc extends Bloc { supportedLocales: state.supportedLocales, ), ); + + // 4. Reload from persistent storage to ensure synchronization + add(const LoadLocale()); } /// Handles the [LoadLocale] event by retrieving it via the use case and updating settings. From 2596249cd2671ae2666b014d651fd6182e64081b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 19:34:14 -0500 Subject: [PATCH 27/38] feat: Remove redundant session handling in getUserSessionData method --- .../data/repositories_impl/home_repository_impl.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index 11f15feb..d06fc4f3 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -99,16 +99,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future getUserSessionData() async { - final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - final dc.ClientBusinessSession? business = session?.business; - - if (business != null) { - return UserSessionData( - businessName: business.businessName, - photoUrl: business.companyLogoUrl, - ); - } - return await _service.run(() async { final String businessId = await _service.getBusinessId(); final QueryResult From 632e0cca3d591433e104acf784ce1639f93a5f10 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 19:44:43 -0500 Subject: [PATCH 28/38] feat: Refactor document upload components to improve file selection and validation --- .../pages/document_upload_page.dart | 102 ++---------------- .../document_file_selector.dart | 76 +++++++++++-- .../pdf_file_types_banner.dart | 14 +++ 3 files changed, 90 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart index 6d962b99..5894b363 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -13,6 +11,7 @@ import '../blocs/document_upload/document_upload_state.dart'; import '../widgets/document_upload/document_attestation_checkbox.dart'; import '../widgets/document_upload/document_file_selector.dart'; import '../widgets/document_upload/document_upload_footer.dart'; +import '../widgets/document_upload/pdf_file_types_banner.dart'; /// Allows staff to select and submit a single PDF document for verification. /// @@ -37,50 +36,6 @@ class DocumentUploadPage extends StatefulWidget { class _DocumentUploadPageState extends State { String? _selectedFilePath; - final FilePickerService _filePicker = Modular.get(); - - static const int _kMaxFileSizeBytes = 10 * 1024 * 1024; - - Future _pickFile() async { - final String? path = await _filePicker.pickFile( - allowedExtensions: ['pdf'], - ); - - if (!mounted) { - return; - } - - if (path != null) { - final String? error = _validatePdfFile(context, path); - if (error != null) { - UiSnackbar.show( - context, - message: error, - type: UiSnackbarType.error, - margin: const EdgeInsets.all(UiConstants.space4), - ); - return; - } - setState(() { - _selectedFilePath = path; - }); - } - } - - String? _validatePdfFile(BuildContext context, String path) { - final File file = File(path); - if (!file.existsSync()) return context.t.common.file_not_found; - final String ext = path.split('.').last.toLowerCase(); - if (ext != 'pdf') { - return context.t.staff_documents.upload.pdf_banner; - } - final int size = file.lengthSync(); - if (size > _kMaxFileSizeBytes) { - return context.t.staff_documents.upload.pdf_banner; - } - return null; - } - @override Widget build(BuildContext context) { if (widget.initialUrl != null) { @@ -118,13 +73,17 @@ class _DocumentUploadPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _PdfFileTypesBanner( + PdfFileTypesBanner( message: t.staff_documents.upload.pdf_banner, ), const SizedBox(height: UiConstants.space6), DocumentFileSelector( selectedFilePath: _selectedFilePath, - onTap: _pickFile, + onFileSelected: (String path) { + setState(() { + _selectedFilePath = path; + }); + }, ), ], ), @@ -152,19 +111,7 @@ class _DocumentUploadPageState extends State { state.status == DocumentUploadStatus.uploading, canSubmit: _selectedFilePath != null && state.isAttested, onSubmit: () { - final String? err = _validatePdfFile( - context, - _selectedFilePath!, - ); - if (err != null) { - UiSnackbar.show( - context, - message: err, - type: UiSnackbarType.error, - margin: const EdgeInsets.all(UiConstants.space4), - ); - return; - } + BlocProvider.of( context, ).uploadDocument( @@ -183,36 +130,3 @@ class _DocumentUploadPageState extends State { ); } } - -/// Banner displaying accepted file types and size limit for PDF upload. -class _PdfFileTypesBanner extends StatelessWidget { - const _PdfFileTypesBanner({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.primaryForeground, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(UiIcons.info, size: 20, color: UiColors.primary), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text(message, style: UiTypography.body2r.textSecondary), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart index 4c112749..abaf5ec5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart @@ -1,7 +1,11 @@ +import 'dart:io'; + import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; // ignore: depend_on_referenced_packages import 'package:core_localization/core_localization.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'document_selected_card.dart'; @@ -9,33 +13,89 @@ import 'document_selected_card.dart'; /// /// Shows the selected file name when a file has been chosen, or an /// upload icon with a prompt when no file is selected yet. -class DocumentFileSelector extends StatelessWidget { +class DocumentFileSelector extends StatefulWidget { const DocumentFileSelector({ super.key, - required this.onTap, + this.onFileSelected, this.selectedFilePath, }); - /// Called when the user taps the selector to pick a file. - final VoidCallback onTap; + /// Called when a file is successfully selected and validated. + final Function(String)? onFileSelected; /// The local path of the currently selected file, or null if none chosen. final String? selectedFilePath; - bool get _hasFile => selectedFilePath != null; + @override + State createState() => _DocumentFileSelectorState(); +} + +class _DocumentFileSelectorState extends State { + late String? _selectedFilePath; + final FilePickerService _filePicker = Modular.get(); + static const int _kMaxFileSizeBytes = 10 * 1024 * 1024; + + @override + void initState() { + super.initState(); + _selectedFilePath = widget.selectedFilePath; + } + + bool get _hasFile => _selectedFilePath != null; + + Future _pickFile() async { + final String? path = await _filePicker.pickFile( + allowedExtensions: ['pdf'], + ); + + if (!mounted) { + return; + } + + if (path != null) { + final String? error = _validatePdfFile(context, path); + if (error != null) { + UiSnackbar.show( + context, + message: error, + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } + setState(() { + _selectedFilePath = path; + }); + widget.onFileSelected?.call(path); + } + } + + String? _validatePdfFile(BuildContext context, String path) { + final File file = File(path); + if (!file.existsSync()) return context.t.common.file_not_found; + final String ext = path.split('.').last.toLowerCase(); + if (ext != 'pdf') { + return context.t.staff_documents.upload.pdf_banner; + } + final int size = file.lengthSync(); + if (size > _kMaxFileSizeBytes) { + return context.t.staff_documents.upload.pdf_banner; + } + return null; + } @override Widget build(BuildContext context) { if (_hasFile) { return InkWell( - onTap: onTap, + onTap: _pickFile, borderRadius: UiConstants.radiusLg, - child: DocumentSelectedCard(selectedFilePath: selectedFilePath!), + child: DocumentSelectedCard(selectedFilePath: _selectedFilePath!), ); } return InkWell( - onTap: onTap, + onTap: _pickFile, borderRadius: UiConstants.radiusLg, child: Container( height: 180, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart new file mode 100644 index 00000000..6c6dabfe --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/pdf_file_types_banner.dart @@ -0,0 +1,14 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner displaying accepted file types and size limit for PDF upload. +class PdfFileTypesBanner extends StatelessWidget { + const PdfFileTypesBanner({required this.message, super.key}); + + final String message; + + @override + Widget build(BuildContext context) { + return UiNoticeBanner(title: message, icon: UiIcons.info); + } +} From e0e7bd51cebc11753544a84b85af962bafded3a0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 20:09:23 -0500 Subject: [PATCH 29/38] feat: Refactor document upload flow to support selected file path management --- .../documents_repository_impl.dart | 14 +----- .../document_upload_cubit.dart | 5 ++ .../document_upload_state.dart | 5 ++ .../pages/document_upload_page.dart | 46 ++++++++----------- 4 files changed, 32 insertions(+), 38 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index de03b8aa..5c0efd76 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -50,21 +50,11 @@ class DocumentsRepositoryImpl implements DocumentsRepository { ); final String description = (currentDoc.description ?? '').toLowerCase(); - String verificationType = 'government_id'; - if (description.contains('permit')) { - verificationType = 'work_permit'; - } else if (description.contains('passport')) { - verificationType = 'passport'; - } else if (description.contains('ssn') || - description.contains('social security')) { - verificationType = 'ssn'; - } - final String staffId = await _service.getStaffId(); final VerificationResponse verificationRes = await _verificationService .createVerification( fileUri: uploadRes.fileUri, - type: verificationType, + type: 'government_id', subjectType: 'worker', subjectId: staffId, rules: { @@ -75,7 +65,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { // 4. Update/Create StaffDocument in Data Connect await _service.getStaffRepository().upsertStaffDocument( documentId: documentId, - documentUrl: uploadRes.fileUri, + documentUrl: signedUrlRes.signedUrl, status: domain.DocumentStatus.pending, verificationId: verificationRes.verificationId, ); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart index 8cb036a6..89cd8d86 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart @@ -19,6 +19,11 @@ class DocumentUploadCubit extends Cubit { emit(state.copyWith(isAttested: value)); } + /// Sets the selected file path for the document. + void setSelectedFilePath(String filePath) { + emit(state.copyWith(selectedFilePath: filePath)); + } + /// Uploads the selected document if the user has attested. /// /// Requires [state.isAttested] to be true before proceeding. diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart index a737b1a1..eb92a3e0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart @@ -7,6 +7,7 @@ class DocumentUploadState extends Equatable { const DocumentUploadState({ this.status = DocumentUploadStatus.initial, this.isAttested = false, + this.selectedFilePath, this.documentUrl, this.updatedDocument, this.errorMessage, @@ -14,6 +15,7 @@ class DocumentUploadState extends Equatable { final DocumentUploadStatus status; final bool isAttested; + final String? selectedFilePath; final String? documentUrl; final StaffDocument? updatedDocument; final String? errorMessage; @@ -21,6 +23,7 @@ class DocumentUploadState extends Equatable { DocumentUploadState copyWith({ DocumentUploadStatus? status, bool? isAttested, + String? selectedFilePath, String? documentUrl, StaffDocument? updatedDocument, String? errorMessage, @@ -28,6 +31,7 @@ class DocumentUploadState extends Equatable { return DocumentUploadState( status: status ?? this.status, isAttested: isAttested ?? this.isAttested, + selectedFilePath: selectedFilePath ?? this.selectedFilePath, documentUrl: documentUrl ?? this.documentUrl, updatedDocument: updatedDocument ?? this.updatedDocument, errorMessage: errorMessage ?? this.errorMessage, @@ -38,6 +42,7 @@ class DocumentUploadState extends Equatable { List get props => [ status, isAttested, + selectedFilePath, documentUrl, updatedDocument, errorMessage, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart index 5894b363..13dbe8df 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -17,7 +17,7 @@ import '../widgets/document_upload/pdf_file_types_banner.dart'; /// /// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow: /// file selection → attestation → submit → poll for result. -class DocumentUploadPage extends StatefulWidget { +class DocumentUploadPage extends StatelessWidget { const DocumentUploadPage({ super.key, required this.document, @@ -30,20 +30,17 @@ class DocumentUploadPage extends StatefulWidget { /// Optional URL of an already-uploaded document. final String? initialUrl; - @override - State createState() => _DocumentUploadPageState(); -} - -class _DocumentUploadPageState extends State { - String? _selectedFilePath; @override Widget build(BuildContext context) { - if (widget.initialUrl != null) { - _selectedFilePath = widget.initialUrl; - } - return BlocProvider( - create: (BuildContext _) => Modular.get(), + create: (BuildContext _) { + final DocumentUploadCubit cubit = + Modular.get(); + if (initialUrl != null) { + cubit.setSelectedFilePath(initialUrl!); + } + return cubit; + }, child: BlocConsumer( listener: (BuildContext context, DocumentUploadState state) { if (state.status == DocumentUploadStatus.success) { @@ -64,8 +61,8 @@ class _DocumentUploadPageState extends State { builder: (BuildContext context, DocumentUploadState state) { return Scaffold( appBar: UiAppBar( - title: widget.document.name, - subtitle: widget.document.description, + title: document.name, + subtitle: document.description, onLeadingPressed: () => Modular.to.toDocuments(), ), body: SingleChildScrollView( @@ -78,11 +75,10 @@ class _DocumentUploadPageState extends State { ), const SizedBox(height: UiConstants.space6), DocumentFileSelector( - selectedFilePath: _selectedFilePath, + selectedFilePath: state.selectedFilePath, onFileSelected: (String path) { - setState(() { - _selectedFilePath = path; - }); + BlocProvider.of(context) + .setSelectedFilePath(path); }, ), ], @@ -109,15 +105,13 @@ class _DocumentUploadPageState extends State { DocumentUploadFooter( isUploading: state.status == DocumentUploadStatus.uploading, - canSubmit: _selectedFilePath != null && state.isAttested, + canSubmit: state.selectedFilePath != null && state.isAttested, onSubmit: () { - - BlocProvider.of( - context, - ).uploadDocument( - widget.document.documentId, - _selectedFilePath!, - ); + BlocProvider.of(context) + .uploadDocument( + document.documentId, + state.selectedFilePath!, + ); }, ), ], From 5795f7c45d98c8c1658a7216a01af88a9c151c19 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 20:28:13 -0500 Subject: [PATCH 30/38] feat: Update certificate upload process to use signed URLs and enable fields for new certificates --- .../certificates_repository_impl.dart | 16 ++++++---------- .../pages/certificate_upload_page.dart | 12 +++++++----- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 70827588..bd4fbaf5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -46,24 +46,20 @@ class CertificatesRepositoryImpl implements CertificatesRepository { // 2. Generate a signed URL for verification service to access the file // Wait, verification service might need this or just the URI. // Following DocumentRepository behavior: - await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); // 3. Initiate verification - final List allCerts = await getCertificates(); - final domain.StaffCertificate currentCert = allCerts.firstWhere( - (domain.StaffCertificate c) => c.certificationType == certificationType, - ); - final String staffId = await _service.getStaffId(); final VerificationResponse verificationRes = await _verificationService .createVerification( fileUri: uploadRes.fileUri, - type: certificationType.value, - category: 'certification', + type: 'certification', subjectType: 'worker', subjectId: staffId, rules: { - 'certificateDescription': currentCert.description, + 'certificateName': name, + 'certificateIssuer': issuer, + 'certificateNumber': certificateNumber, }, ); @@ -72,7 +68,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { certificationType: certificationType, name: name, status: domain.StaffCertificateStatus.pending, - fileUrl: uploadRes.fileUri, + fileUrl: signedUrlRes.signedUrl, expiry: expiryDate, issuer: issuer, certificateNumber: certificateNumber, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 39259bfd..74ec77f3 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -35,6 +35,8 @@ class _CertificateUploadPageState extends State { final FilePickerService _filePicker = Modular.get(); + bool get _isNewCertificate => widget.certificate == null; + @override void initState() { super.initState(); @@ -145,8 +147,8 @@ class _CertificateUploadPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (BuildContext _) => Modular.get(), + return BlocProvider.value( + value: Modular.get(), child: BlocConsumer( listener: (BuildContext context, CertificateUploadState state) { if (state.status == CertificateUploadStatus.success) { @@ -190,7 +192,7 @@ class _CertificateUploadPageState extends State { const SizedBox(height: UiConstants.space2), TextField( controller: _nameController, - enabled: false, + enabled: _isNewCertificate, decoration: InputDecoration( hintText: t.staff_certificates.upload_modal.name_hint, border: OutlineInputBorder( @@ -208,7 +210,7 @@ class _CertificateUploadPageState extends State { const SizedBox(height: UiConstants.space2), TextField( controller: _issuerController, - enabled: false, + enabled: _isNewCertificate, decoration: InputDecoration( hintText: t.staff_certificates.upload_modal.issuer_hint, border: OutlineInputBorder( @@ -226,7 +228,7 @@ class _CertificateUploadPageState extends State { const SizedBox(height: UiConstants.space2), TextField( controller: _numberController, - enabled: false, + enabled: _isNewCertificate, decoration: InputDecoration( hintText: 'Enter number if applicable', border: OutlineInputBorder( From b0abd68c2e771e98b76a982b6fa04d68f7b1ed66 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 20:35:22 -0500 Subject: [PATCH 31/38] feat: Implement certificate upload form with metadata fields, expiry date selection, and file upload functionality --- .../pages/certificate_upload_page.dart | 342 +++--------------- .../certificate_metadata_fields.dart | 79 ++++ .../certificate_upload_actions.dart | 101 ++++++ .../expiry_date_field.dart | 60 +++ .../file_selector.dart | 75 ++++ .../certificate_upload_page/index.dart | 5 + .../pdf_file_types_banner.dart | 35 ++ 7 files changed, 400 insertions(+), 297 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/index.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 74ec77f3..d8dd6456 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -1,17 +1,17 @@ import 'dart:io'; +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:intl/intl.dart'; +import '../../domain/usecases/upload_certificate_usecase.dart'; import '../blocs/certificate_upload/certificate_upload_cubit.dart'; import '../blocs/certificate_upload/certificate_upload_state.dart'; -import '../../domain/usecases/upload_certificate_usecase.dart'; +import '../widgets/certificate_upload_page/index.dart'; /// Page for uploading a certificate with metadata (expiry, issuer, etc). class CertificateUploadPage extends StatefulWidget { @@ -179,62 +179,16 @@ class _CertificateUploadPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _PdfFileTypesBanner( + PdfFileTypesBanner( message: t.staff_documents.upload.pdf_banner, ), const SizedBox(height: UiConstants.space6), - // Name Field - Text( - t.staff_certificates.upload_modal.name_label, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - TextField( - controller: _nameController, - enabled: _isNewCertificate, - decoration: InputDecoration( - hintText: t.staff_certificates.upload_modal.name_hint, - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - // Issuer Field - Text( - t.staff_certificates.upload_modal.issuer_label, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - TextField( - controller: _issuerController, - enabled: _isNewCertificate, - decoration: InputDecoration( - hintText: t.staff_certificates.upload_modal.issuer_hint, - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - ), - const SizedBox(height: UiConstants.space4), - - // Certificate Number Field - Text( - 'Certificate Number', - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - TextField( - controller: _numberController, - enabled: _isNewCertificate, - decoration: InputDecoration( - hintText: 'Enter number if applicable', - border: OutlineInputBorder( - borderRadius: UiConstants.radiusLg, - ), - ), + CertificateMetadataFields( + nameController: _nameController, + issuerController: _issuerController, + numberController: _numberController, + isNewCertificate: _isNewCertificate, ), const SizedBox(height: UiConstants.space6), @@ -242,44 +196,9 @@ class _CertificateUploadPageState extends State { const SizedBox(height: UiConstants.space6), - // Expiry Date Field - Text( - t.staff_certificates.upload_modal.expiry_label, - style: UiTypography.body2m.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - InkWell( + ExpiryDateField( + selectedDate: _selectedExpiryDate, onTap: _selectDate, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - border: Border.all(color: UiColors.border), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - children: [ - const Icon( - UiIcons.calendar, - size: 20, - color: UiColors.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - Text( - _selectedExpiryDate != null - ? DateFormat( - 'MMM dd, yyyy', - ).format(_selectedExpiryDate!) - : t.staff_certificates.upload_modal.select_date, - style: _selectedExpiryDate != null - ? UiTypography.body1m.textPrimary - : UiTypography.body1m.textSecondary, - ), - ], - ), - ), ), const SizedBox(height: UiConstants.space4), @@ -289,7 +208,7 @@ class _CertificateUploadPageState extends State { style: UiTypography.body2m.textPrimary, ), const SizedBox(height: UiConstants.space2), - _FileSelector( + FileSelector( selectedFilePath: _selectedFilePath, onTap: _pickFile, ), @@ -299,110 +218,40 @@ class _CertificateUploadPageState extends State { bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: UiConstants.space4, - children: [ - // Attestation - Row( - children: [ - Checkbox( - value: state.isAttested, - onChanged: (bool? val) => - BlocProvider.of( - context, - ).setAttested(val ?? false), - activeColor: UiColors.primary, - ), - Expanded( - child: Text( - t.staff_documents.upload.attestation, - style: UiTypography.body3r.textSecondary, - ), - ), - ], - ), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: - (_selectedFilePath != null && - state.isAttested && - _nameController.text.isNotEmpty) - ? () { - final String? err = _validatePdfFile( - context, - _selectedFilePath!, - ); - if (err != null) { - UiSnackbar.show( - context, - message: err, - type: UiSnackbarType.error, - margin: const EdgeInsets.all( - UiConstants.space4, - ), - ); - return; - } - BlocProvider.of( - context, - ).uploadCertificate( - UploadCertificateParams( - certificationType: _selectedType!, - name: _nameController.text, - filePath: _selectedFilePath!, - expiryDate: _selectedExpiryDate, - issuer: _issuerController.text, - certificateNumber: _numberController.text, - ), - ); - } - : null, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusLg, - ), - ), - child: state.status == CertificateUploadStatus.uploading - ? const CircularProgressIndicator( - color: Colors.white, - ) - : Text( - t.staff_certificates.upload_modal.save, - style: UiTypography.body1m.white, - ), + child: CertificateUploadActions( + isAttested: state.isAttested, + isFormValid: _selectedFilePath != null && + state.isAttested && + _nameController.text.isNotEmpty, + isUploading: state.status == CertificateUploadStatus.uploading, + hasExistingCertificate: widget.certificate != null, + onUploadPressed: () { + final String? err = _validatePdfFile( + context, + _selectedFilePath!, + ); + if (err != null) { + UiSnackbar.show( + context, + message: err, + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } + BlocProvider.of(context) + .uploadCertificate( + UploadCertificateParams( + certificationType: _selectedType!, + name: _nameController.text, + filePath: _selectedFilePath!, + expiryDate: _selectedExpiryDate, + issuer: _issuerController.text, + certificateNumber: _numberController.text, ), - ), - - // Remove Button (only if existing) - if (widget.certificate != null) ...[ - SizedBox( - width: double.infinity, - child: TextButton.icon( - onPressed: () => _showRemoveConfirmation(context), - icon: const Icon(UiIcons.delete, size: 20), - label: Text(t.staff_certificates.card.remove), - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: UiConstants.radiusLg, - side: const BorderSide( - color: UiColors.destructive, - ), - ), - ), - ), - ), - ], - ], + ); + }, + onRemovePressed: () => _showRemoveConfirmation(context), ), ), ), @@ -412,104 +261,3 @@ class _CertificateUploadPageState extends State { ); } } - -/// Banner displaying accepted file types and size limit for PDF upload. -class _PdfFileTypesBanner extends StatelessWidget { - const _PdfFileTypesBanner({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.primaryForeground, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.info, size: 20, color: UiColors.primary), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text(message, style: UiTypography.body2r.textSecondary), - ), - ], - ), - ); - } -} - -class _FileSelector extends StatelessWidget { - const _FileSelector({this.selectedFilePath, required this.onTap}); - - final String? selectedFilePath; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - if (selectedFilePath != null) { - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - border: Border.all(color: UiColors.primary), - borderRadius: UiConstants.radiusLg, - ), - child: Row( - children: [ - const Icon(UiIcons.certificate, color: UiColors.primary), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text( - selectedFilePath!.split('/').last, - style: UiTypography.body1m.primary, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - t.staff_documents.upload.replace, - style: UiTypography.body3m.primary, - ), - ], - ), - ), - ); - } - - return InkWell( - onTap: onTap, - child: Container( - height: 120, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: UiColors.border, style: BorderStyle.solid), - borderRadius: UiConstants.radiusLg, - color: UiColors.background, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary), - const SizedBox(height: UiConstants.space2), - Text( - t.staff_certificates.upload_modal.drag_drop, - style: UiTypography.body2m, - ), - Text( - t.staff_certificates.upload_modal.supported_formats, - style: UiTypography.body3r.textSecondary, - ), - ], - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart new file mode 100644 index 00000000..e8849918 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_metadata_fields.dart @@ -0,0 +1,79 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; + +/// Widget for certificate metadata input fields (name, issuer, number). +class CertificateMetadataFields extends StatelessWidget { + const CertificateMetadataFields({ + required this.nameController, + required this.issuerController, + required this.numberController, + required this.isNewCertificate, + }); + + final TextEditingController nameController; + final TextEditingController issuerController; + final TextEditingController numberController; + final bool isNewCertificate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name Field + Text( + t.staff_certificates.upload_modal.name_label, + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + TextField( + controller: nameController, + enabled: isNewCertificate, + decoration: InputDecoration( + hintText: t.staff_certificates.upload_modal.name_hint, + border: OutlineInputBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + // Issuer Field + Text( + t.staff_certificates.upload_modal.issuer_label, + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + TextField( + controller: issuerController, + enabled: isNewCertificate, + decoration: InputDecoration( + hintText: t.staff_certificates.upload_modal.issuer_hint, + border: OutlineInputBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + // Certificate Number Field + Text( + 'Certificate Number', + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + TextField( + controller: numberController, + enabled: isNewCertificate, + decoration: InputDecoration( + hintText: 'Enter number if applicable', + border: OutlineInputBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart new file mode 100644 index 00000000..3887e5df --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/certificate_upload_actions.dart @@ -0,0 +1,101 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:core_localization/core_localization.dart'; + +import '../../blocs/certificate_upload/certificate_upload_cubit.dart'; + +/// Widget for attestation checkbox and action buttons in certificate upload form. +class CertificateUploadActions extends StatelessWidget { + const CertificateUploadActions({ + required this.isAttested, + required this.isFormValid, + required this.isUploading, + required this.hasExistingCertificate, + required this.onUploadPressed, + required this.onRemovePressed, + }); + + final bool isAttested; + final bool isFormValid; + final bool isUploading; + final bool hasExistingCertificate; + final VoidCallback onUploadPressed; + final VoidCallback onRemovePressed; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + spacing: UiConstants.space4, + children: [ + // Attestation + Row( + children: [ + Checkbox( + value: isAttested, + onChanged: (bool? val) => + BlocProvider.of(context).setAttested( + val ?? false, + ), + activeColor: UiColors.primary, + ), + Expanded( + child: Text( + t.staff_documents.upload.attestation, + style: UiTypography.body3r.textSecondary, + ), + ), + ], + ), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isFormValid ? onUploadPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + ), + ), + child: isUploading + ? const CircularProgressIndicator( + color: Colors.white, + ) + : Text( + t.staff_certificates.upload_modal.save, + style: UiTypography.body1m.white, + ), + ), + ), + + // Remove Button (only if existing) + if (hasExistingCertificate) ...[ + SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: onRemovePressed, + icon: const Icon(UiIcons.delete, size: 20), + label: Text(t.staff_certificates.card.remove), + style: TextButton.styleFrom( + foregroundColor: UiColors.destructive, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide( + color: UiColors.destructive, + ), + ), + ), + ), + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart new file mode 100644 index 00000000..43fd484d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/expiry_date_field.dart @@ -0,0 +1,60 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:core_localization/core_localization.dart'; + +/// Widget for selecting certificate expiry date. +class ExpiryDateField extends StatelessWidget { + const ExpiryDateField({ + required this.selectedDate, + required this.onTap, + }); + + final DateTime? selectedDate; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.staff_certificates.upload_modal.expiry_label, + style: UiTypography.body2m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.textSecondary, + ), + const SizedBox(width: UiConstants.space3), + Text( + selectedDate != null + ? DateFormat('MMM dd, yyyy').format(selectedDate!) + : t.staff_certificates.upload_modal.select_date, + style: selectedDate != null + ? UiTypography.body1m.textPrimary + : UiTypography.body1m.textSecondary, + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart new file mode 100644 index 00000000..a770779d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart @@ -0,0 +1,75 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; + +/// Widget for selecting certificate file. +class FileSelector extends StatelessWidget { + const FileSelector({ + required this.selectedFilePath, + required this.onTap, + }); + + final String? selectedFilePath; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (selectedFilePath != null) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.primary), + borderRadius: UiConstants.radiusLg, + ), + child: Row( + children: [ + const Icon(UiIcons.certificate, color: UiColors.primary), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text( + selectedFilePath!.split('/').last, + style: UiTypography.body1m.primary, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + t.staff_documents.upload.replace, + style: UiTypography.body3m.primary, + ), + ], + ), + ), + ); + } + + return InkWell( + onTap: onTap, + child: Container( + height: 120, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, style: BorderStyle.solid), + borderRadius: UiConstants.radiusLg, + color: UiColors.background, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary), + const SizedBox(height: UiConstants.space2), + Text( + t.staff_certificates.upload_modal.drag_drop, + style: UiTypography.body2m, + ), + Text( + t.staff_certificates.upload_modal.supported_formats, + style: UiTypography.body3r.textSecondary, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/index.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/index.dart new file mode 100644 index 00000000..bda59ee4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/index.dart @@ -0,0 +1,5 @@ +export 'certificate_metadata_fields.dart'; +export 'certificate_upload_actions.dart'; +export 'expiry_date_field.dart'; +export 'file_selector.dart'; +export 'pdf_file_types_banner.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart new file mode 100644 index 00000000..5c5252df --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart @@ -0,0 +1,35 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Banner displaying accepted file types and size limit for PDF upload. +class PdfFileTypesBanner extends StatelessWidget { + const PdfFileTypesBanner({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.primaryForeground, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(UiIcons.info, size: 20, color: UiColors.primary), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Text(message, style: UiTypography.body2r.textSecondary), + ), + ], + ), + ); + } +} From 973b8b8ac3fb6f426273525da3bee92c60f175a3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:03:01 -0500 Subject: [PATCH 32/38] feat: Add file path management for certificate uploads in the mobile app --- .../certificate_upload_cubit.dart | 4 ++++ .../certificate_upload_state.dart | 5 +++++ .../pages/certificate_upload_page.dart | 22 ++++++++++--------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart index 4cb2b2e4..a70bc69e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart @@ -19,6 +19,10 @@ class CertificateUploadCubit extends Cubit emit(state.copyWith(isAttested: value)); } + void setSelectedFilePath(String? filePath) { + emit(state.copyWith(selectedFilePath: filePath)); + } + Future deleteCertificate(ComplianceType type) async { emit(state.copyWith(status: CertificateUploadStatus.uploading)); await handleError( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart index 31ea5991..2998a940 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart @@ -7,24 +7,28 @@ class CertificateUploadState extends Equatable { const CertificateUploadState({ this.status = CertificateUploadStatus.initial, this.isAttested = false, + this.selectedFilePath, this.updatedCertificate, this.errorMessage, }); final CertificateUploadStatus status; final bool isAttested; + final String? selectedFilePath; final StaffCertificate? updatedCertificate; final String? errorMessage; CertificateUploadState copyWith({ CertificateUploadStatus? status, bool? isAttested, + String? selectedFilePath, StaffCertificate? updatedCertificate, String? errorMessage, }) { return CertificateUploadState( status: status ?? this.status, isAttested: isAttested ?? this.isAttested, + selectedFilePath: selectedFilePath ?? this.selectedFilePath, updatedCertificate: updatedCertificate ?? this.updatedCertificate, errorMessage: errorMessage ?? this.errorMessage, ); @@ -34,6 +38,7 @@ class CertificateUploadState extends Equatable { List get props => [ status, isAttested, + selectedFilePath, updatedCertificate, errorMessage, ]; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index d8dd6456..287852ad 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -25,7 +25,6 @@ class CertificateUploadPage extends StatefulWidget { } class _CertificateUploadPageState extends State { - String? _selectedFilePath; DateTime? _selectedExpiryDate; final TextEditingController _issuerController = TextEditingController(); final TextEditingController _numberController = TextEditingController(); @@ -37,16 +36,19 @@ class _CertificateUploadPageState extends State { bool get _isNewCertificate => widget.certificate == null; + late CertificateUploadCubit _cubit; + @override void initState() { super.initState(); + _cubit = Modular.get(); + if (widget.certificate != null) { _selectedExpiryDate = widget.certificate!.expiryDate; _issuerController.text = widget.certificate!.issuer ?? ''; _numberController.text = widget.certificate!.certificateNumber ?? ''; _nameController.text = widget.certificate!.name; _selectedType = widget.certificate!.certificationType; - _selectedFilePath = widget.certificate?.certificateUrl; } else { _selectedType = ComplianceType.other; } @@ -82,9 +84,7 @@ class _CertificateUploadPageState extends State { ); return; } - setState(() { - _selectedFilePath = path; - }); + _cubit.setSelectedFilePath(path); } } @@ -148,7 +148,9 @@ class _CertificateUploadPageState extends State { @override Widget build(BuildContext context) { return BlocProvider.value( - value: Modular.get(), + value: _cubit..setSelectedFilePath( + widget.certificate?.certificateUrl, + ), child: BlocConsumer( listener: (BuildContext context, CertificateUploadState state) { if (state.status == CertificateUploadStatus.success) { @@ -209,7 +211,7 @@ class _CertificateUploadPageState extends State { ), const SizedBox(height: UiConstants.space2), FileSelector( - selectedFilePath: _selectedFilePath, + selectedFilePath: state.selectedFilePath, onTap: _pickFile, ), ], @@ -220,7 +222,7 @@ class _CertificateUploadPageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: CertificateUploadActions( isAttested: state.isAttested, - isFormValid: _selectedFilePath != null && + isFormValid: state.selectedFilePath != null && state.isAttested && _nameController.text.isNotEmpty, isUploading: state.status == CertificateUploadStatus.uploading, @@ -228,7 +230,7 @@ class _CertificateUploadPageState extends State { onUploadPressed: () { final String? err = _validatePdfFile( context, - _selectedFilePath!, + state.selectedFilePath!, ); if (err != null) { UiSnackbar.show( @@ -244,7 +246,7 @@ class _CertificateUploadPageState extends State { UploadCertificateParams( certificationType: _selectedType!, name: _nameController.text, - filePath: _selectedFilePath!, + filePath: state.selectedFilePath!, expiryDate: _selectedExpiryDate, issuer: _issuerController.text, certificateNumber: _numberController.text, From 5b4b8b71389ad71629e1248b3cc6c4f995786de7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:05:48 -0500 Subject: [PATCH 33/38] feat: Update file selector and PDF types banner to use super.key for improved widget initialization --- .../file_selector.dart | 1 + .../pdf_file_types_banner.dart | 25 ++----------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart index a770779d..8959ffb9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/file_selector.dart @@ -5,6 +5,7 @@ import 'package:core_localization/core_localization.dart'; /// Widget for selecting certificate file. class FileSelector extends StatelessWidget { const FileSelector({ + super.key, required this.selectedFilePath, required this.onTap, }); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart index 5c5252df..9aeb971f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_page/pdf_file_types_banner.dart @@ -3,33 +3,12 @@ import 'package:flutter/material.dart'; /// Banner displaying accepted file types and size limit for PDF upload. class PdfFileTypesBanner extends StatelessWidget { - const PdfFileTypesBanner({required this.message}); + const PdfFileTypesBanner({super.key, required this.message}); final String message; @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: UiConstants.space3, - ), - decoration: BoxDecoration( - color: UiColors.primaryForeground, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.info, size: 20, color: UiColors.primary), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text(message, style: UiTypography.body2r.textSecondary), - ), - ], - ), - ); + return UiNoticeBanner(title: message, icon: UiIcons.info); } } From 49ecede35f57acacf4dc2098fa0970b3a92cff54 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:11:05 -0500 Subject: [PATCH 34/38] feat: Remove redundant PDF file validation before certificate upload --- .../presentation/pages/certificate_upload_page.dart | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 287852ad..3a15d10a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -228,19 +228,6 @@ class _CertificateUploadPageState extends State { isUploading: state.status == CertificateUploadStatus.uploading, hasExistingCertificate: widget.certificate != null, onUploadPressed: () { - final String? err = _validatePdfFile( - context, - state.selectedFilePath!, - ); - if (err != null) { - UiSnackbar.show( - context, - message: err, - type: UiSnackbarType.error, - margin: const EdgeInsets.all(UiConstants.space4), - ); - return; - } BlocProvider.of(context) .uploadCertificate( UploadCertificateParams( From 8e95589551e4293d7af666bb7405f525ca2bd830 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:17:45 -0500 Subject: [PATCH 35/38] feat: Enhance certificate upload process with file change verification and signed URL generation --- .../certificates_repository_impl.dart | 77 +++++++++++-------- .../presentation/pages/bank_account_page.dart | 14 +--- .../presentation/pages/time_card_page.dart | 25 ++---- .../lib/src/presentation/pages/faqs_page.dart | 4 - .../pages/legal/privacy_policy_page.dart | 4 - .../pages/legal/terms_of_service_page.dart | 4 - .../pages/privacy_security_page.dart | 4 - 7 files changed, 56 insertions(+), 76 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index bd4fbaf5..f816eff4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -35,46 +35,63 @@ class CertificatesRepositoryImpl implements CertificatesRepository { String? certificateNumber, }) async { return _service.run(() async { - // 1. Upload the file to cloud storage - final FileUploadResponse uploadRes = await _uploadService.uploadFile( - filePath: filePath, - fileName: - 'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf', - visibility: domain.FileVisibility.private, - ); + // Get existing certificate to check if file has changed + final List existingCerts = await getCertificates(); + domain.StaffCertificate? existingCert; + try { + existingCert = existingCerts.firstWhere( + (domain.StaffCertificate c) => c.certificationType == certificationType, + ); + } catch (e) { + // Certificate doesn't exist yet + } - // 2. Generate a signed URL for verification service to access the file - // Wait, verification service might need this or just the URI. - // Following DocumentRepository behavior: - final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + String? signedUrl = existingCert?.certificateUrl; + String? verificationId = existingCert?.verificationId; + final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath; - // 3. Initiate verification - final String staffId = await _service.getStaffId(); - final VerificationResponse verificationRes = await _verificationService - .createVerification( - fileUri: uploadRes.fileUri, - type: 'certification', - subjectType: 'worker', - subjectId: staffId, - rules: { - 'certificateName': name, - 'certificateIssuer': issuer, - 'certificateNumber': certificateNumber, - }, - ); + // Only upload and verify if file path has changed + if (fileChanged) { + // 1. Upload the file to cloud storage + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf', + visibility: domain.FileVisibility.private, + ); + + // 2. Generate a signed URL for verification service to access the file + final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + signedUrl = signedUrlRes.signedUrl; + + // 3. Initiate verification + final String staffId = await _service.getStaffId(); + final VerificationResponse verificationRes = await _verificationService + .createVerification( + fileUri: uploadRes.fileUri, + type: 'certification', + subjectType: 'worker', + subjectId: staffId, + rules: { + 'certificateName': name, + 'certificateIssuer': issuer, + 'certificateNumber': certificateNumber, + }, + ); + verificationId = verificationRes.verificationId; + } // 4. Update/Create Certificate in Data Connect await _service.getStaffRepository().upsertStaffCertificate( certificationType: certificationType, name: name, - status: domain.StaffCertificateStatus.pending, - fileUrl: signedUrlRes.signedUrl, + status: existingCert?.status ?? domain.StaffCertificateStatus.pending, + fileUrl: signedUrl, expiry: expiryDate, issuer: issuer, certificateNumber: certificateNumber, - validationStatus: - domain.StaffCertificateValidationStatus.pendingExpertReview, - verificationId: verificationRes.verificationId, + validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview, + verificationId: verificationId, ); // 5. Return updated list or the specific certificate diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index b81bae6f..1d9fd651 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -78,22 +78,16 @@ class BankAccountPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SecurityNotice(strings: strings), - const SizedBox(height: UiConstants.space32), - if (state.accounts.isEmpty) + if (state.accounts.isEmpty) ...[ + const SizedBox(height: UiConstants.space32), const UiEmptyState( icon: UiIcons.building, title: 'No accounts yet', description: 'Add your first bank account to get started', - ) - else ...[ - Text( - strings.linked_accounts, - style: UiTypography.headline4m.copyWith( - color: UiColors.textPrimary, - ), ), - const SizedBox(height: UiConstants.space3), + ] else ...[ + const SizedBox(height: UiConstants.space4), ...state.accounts.map( (StaffBankAccount account) => AccountCard(account: account, strings: strings), diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index 5c66f590..80f5a327 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -19,11 +19,12 @@ class TimeCardPage extends StatefulWidget { } class _TimeCardPageState extends State { - final TimeCardBloc _bloc = Modular.get(); + late final TimeCardBloc _bloc; @override void initState() { super.initState(); + _bloc = Modular.get(); _bloc.add(LoadTimeCards(DateTime.now())); } @@ -33,25 +34,9 @@ class _TimeCardPageState extends State { return BlocProvider.value( value: _bloc, child: Scaffold( - backgroundColor: UiColors.bgPrimary, - appBar: AppBar( - backgroundColor: UiColors.bgPopup, - elevation: 0, - leading: IconButton( - icon: const Icon( - UiIcons.chevronLeft, - color: UiColors.iconSecondary, - ), - onPressed: () => Modular.to.popSafe(), - ), - title: Text( - t.staff_time_card.title, - style: UiTypography.headline4m.textPrimary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), + appBar: UiAppBar( + title: t.staff_time_card.title, + showBackButton: true, ), body: BlocConsumer( listener: (BuildContext context, TimeCardState state) { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart index 1c99a9ab..b1598d5b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart @@ -17,10 +17,6 @@ class FaqsPage extends StatelessWidget { appBar: UiAppBar( title: t.staff_faqs.title, showBackButton: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border, height: 1), - ), ), body: BlocProvider( create: (BuildContext context) => diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart index 510eca63..1f9c0379 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart @@ -18,10 +18,6 @@ class PrivacyPolicyPage extends StatelessWidget { appBar: UiAppBar( title: t.staff_privacy_security.privacy_policy.title, showBackButton: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border, height: 1), - ), ), body: BlocProvider( create: (BuildContext context) => Modular.get()..fetchPrivacyPolicy(), diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart index 8bd8daae..e5e30c13 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart @@ -18,10 +18,6 @@ class TermsOfServicePage extends StatelessWidget { appBar: UiAppBar( title: t.staff_privacy_security.terms_of_service.title, showBackButton: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border, height: 1), - ), ), body: BlocProvider( create: (BuildContext context) => Modular.get()..fetchTerms(), diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart index 28749dbe..df83b2cd 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart @@ -18,10 +18,6 @@ class PrivacySecurityPage extends StatelessWidget { appBar: UiAppBar( title: t.staff_privacy_security.title, showBackButton: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border, height: 1), - ), ), body: BlocProvider.value( value: Modular.get() From 2e043f35b93651dadcf71ad76c3ec53b0df5b554 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:18:41 -0500 Subject: [PATCH 36/38] feat: Refactor ClockInPage for improved readability and consistency in code formatting --- .../src/presentation/pages/clock_in_page.dart | 185 +++++++++++------- 1 file changed, 112 insertions(+), 73 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 3e6ce143..8c56911a 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -33,7 +33,9 @@ class _ClockInPageState extends State { @override Widget build(BuildContext context) { - final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; return BlocProvider.value( value: _bloc, child: BlocConsumer( @@ -60,22 +62,17 @@ class _ClockInPageState extends State { final String? activeShiftId = state.attendance.activeShiftId; final bool isActiveSelected = selectedShift != null && selectedShift.id == activeShiftId; - final DateTime? checkInTime = - isActiveSelected ? state.attendance.checkInTime : null; - final DateTime? checkOutTime = - isActiveSelected ? state.attendance.checkOutTime : null; + final DateTime? checkInTime = isActiveSelected + ? state.attendance.checkInTime + : null; + final DateTime? checkOutTime = isActiveSelected + ? state.attendance.checkOutTime + : null; final bool isCheckedIn = state.attendance.isCheckedIn && isActiveSelected; return Scaffold( - appBar: UiAppBar( - titleWidget: Text( - i18n.title, - style: UiTypography.title1m.textPrimary, - ), - showBackButton: false, - centerTitle: false, - ), + appBar: UiAppBar(title: i18n.title, showBackButton: false), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.only( @@ -141,17 +138,14 @@ class _ClockInPageState extends State { ), decoration: BoxDecoration( color: UiColors.white, - borderRadius: - UiConstants.radiusLg, + borderRadius: UiConstants.radiusLg, border: Border.all( - color: shift.id == - selectedShift?.id + color: shift.id == selectedShift?.id ? UiColors.primary : UiColors.border, - width: - shift.id == selectedShift?.id - ? 2 - : 1, + width: shift.id == selectedShift?.id + ? 2 + : 1, ), ), child: Row( @@ -163,23 +157,23 @@ class _ClockInPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - shift.id == - selectedShift?.id - ? i18n - .selected_shift_badge - : i18n - .today_shift_badge, - style: UiTypography - .titleUppercase4b - .copyWith( - color: shift.id == - selectedShift?.id - ? UiColors.primary - : UiColors - .textSecondary, - ), - ), + Text( + shift.id == + selectedShift?.id + ? i18n.selected_shift_badge + : i18n.today_shift_badge, + style: UiTypography + .titleUppercase4b + .copyWith( + color: + shift.id == + selectedShift + ?.id + ? UiColors.primary + : UiColors + .textSecondary, + ), + ), const SizedBox(height: 2), Text( shift.title, @@ -187,7 +181,8 @@ class _ClockInPageState extends State { ), Text( "${shift.clientName} • ${shift.location}", - style: UiTypography.body3r + style: UiTypography + .body3r .textSecondary, ), ], @@ -199,15 +194,16 @@ class _ClockInPageState extends State { children: [ Text( "${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}", - style: UiTypography.body3m + style: UiTypography + .body3m .textSecondary, ), Text( "\$${shift.hourlyRate}/hr", style: UiTypography.body3m .copyWith( - color: UiColors.primary, - ), + color: UiColors.primary, + ), ), ], ), @@ -226,8 +222,9 @@ class _ClockInPageState extends State { !_isCheckInAllowed(selectedShift)) Container( width: double.infinity, - padding: - const EdgeInsets.all(UiConstants.space6), + padding: const EdgeInsets.all( + UiConstants.space6, + ), decoration: BoxDecoration( color: UiColors.bgSecondary, borderRadius: UiConstants.radiusLg, @@ -261,8 +258,12 @@ class _ClockInPageState extends State { // Attire Photo Section if (!isCheckedIn) ...[ Container( - padding: const EdgeInsets.all(UiConstants.space4), - margin: const EdgeInsets.only(bottom: UiConstants.space4), + padding: const EdgeInsets.all( + UiConstants.space4, + ), + margin: const EdgeInsets.only( + bottom: UiConstants.space4, + ), decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, @@ -277,15 +278,27 @@ class _ClockInPageState extends State { color: UiColors.bgSecondary, borderRadius: UiConstants.radiusMd, ), - child: const Icon(UiIcons.camera, color: UiColors.primary), + child: const Icon( + UiIcons.camera, + color: UiColors.primary, + ), ), const SizedBox(width: UiConstants.space3), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ - Text(i18n.attire_photo_label, style: UiTypography.body2b), - Text(i18n.attire_photo_desc, style: UiTypography.body3r.textSecondary), + Text( + i18n.attire_photo_label, + style: UiTypography.body2b, + ), + Text( + i18n.attire_photo_desc, + style: UiTypography + .body3r + .textSecondary, + ), ], ), ), @@ -303,25 +316,38 @@ class _ClockInPageState extends State { ), ), ], - - if (!isCheckedIn && (!state.isLocationVerified || state.currentLocation == null)) ...[ + + if (!isCheckedIn && + (!state.isLocationVerified || + state.currentLocation == + null)) ...[ Container( width: double.infinity, - padding: const EdgeInsets.all(UiConstants.space4), - margin: const EdgeInsets.only(bottom: UiConstants.space4), + padding: const EdgeInsets.all( + UiConstants.space4, + ), + margin: const EdgeInsets.only( + bottom: UiConstants.space4, + ), decoration: BoxDecoration( color: UiColors.tagError, borderRadius: UiConstants.radiusLg, ), child: Row( children: [ - const Icon(UiIcons.error, color: UiColors.textError, size: 20), + const Icon( + UiIcons.error, + color: UiColors.textError, + size: 20, + ), const SizedBox(width: UiConstants.space3), Expanded( child: Text( - state.currentLocation == null - ? i18n.location_verifying - : i18n.not_in_range(distance: '500'), + state.currentLocation == null + ? i18n.location_verifying + : i18n.not_in_range( + distance: '500', + ), style: UiTypography.body3m.textError, ), ), @@ -333,7 +359,8 @@ class _ClockInPageState extends State { SwipeToCheckIn( isCheckedIn: isCheckedIn, mode: state.checkInMode, - isDisabled: !isCheckedIn && !state.isLocationVerified, + isDisabled: + !isCheckedIn && !state.isLocationVerified, isLoading: state.status == ClockInStatus.actionInProgress, @@ -554,7 +581,9 @@ class _ClockInPageState extends State { } Future _showNFCDialog(BuildContext context) async { - final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; bool scanned = false; // Using a local navigator context since we are in a dialog @@ -668,8 +697,14 @@ class _ClockInPageState extends State { try { final List parts = timeStr.split(':'); if (parts.length >= 2) { - final DateTime dt = DateTime(2022, 1, 1, int.parse(parts[0]), int.parse(parts[1])); - return DateFormat('h:mm a').format(dt); + final DateTime dt = DateTime( + 2022, + 1, + 1, + int.parse(parts[0]), + int.parse(parts[1]), + ); + return DateFormat('h:mm a').format(dt); } return timeStr; } catch (e) { @@ -683,7 +718,9 @@ class _ClockInPageState extends State { // Parse shift date (e.g. 2024-01-31T09:00:00) // The Shift entity has 'date' which is the start DateTime string final DateTime shiftStart = DateTime.parse(shift.startTime); - final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); + final DateTime windowStart = shiftStart.subtract( + const Duration(minutes: 15), + ); return DateTime.now().isAfter(windowStart); } catch (e) { // Fallback: If parsing fails, allow check in to avoid blocking. @@ -692,15 +729,17 @@ class _ClockInPageState extends State { } String _getCheckInAvailabilityTime(Shift shift) { - try { - final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); - final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); - return DateFormat('h:mm a').format(windowStart); - } catch (e) { - final TranslationsStaffClockInEn i18n = Translations.of(context).staff.clock_in; - return i18n.soon; - } + try { + final DateTime shiftStart = DateTime.parse(shift.startTime.trim()); + final DateTime windowStart = shiftStart.subtract( + const Duration(minutes: 15), + ); + return DateFormat('h:mm a').format(windowStart); + } catch (e) { + final TranslationsStaffClockInEn i18n = Translations.of( + context, + ).staff.clock_in; + return i18n.soon; + } } } - - From be2aae1ba0f3a2c1220c6229826b5fbf636631ba Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 21:35:10 -0500 Subject: [PATCH 37/38] feat: Update keystore file paths in key.properties for client and staff configurations --- apps/mobile/apps/client/android/key.properties | 2 +- apps/mobile/apps/staff/android/app/build.gradle.kts | 5 +++-- apps/mobile/apps/staff/android/key.properties | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/mobile/apps/client/android/key.properties b/apps/mobile/apps/client/android/key.properties index b07f333c..664c5c60 100644 --- a/apps/mobile/apps/client/android/key.properties +++ b/apps/mobile/apps/client/android/key.properties @@ -1,7 +1,7 @@ storePassword=krowwithus keyPassword=krowwithus keyAlias=krow_client_dev -storeFile=app/krow_with_us_client_dev.jks +storeFile=krow_with_us_client_dev.jks ### ### Client diff --git a/apps/mobile/apps/staff/android/app/build.gradle.kts b/apps/mobile/apps/staff/android/app/build.gradle.kts index 135ca04e..9e3968be 100644 --- a/apps/mobile/apps/staff/android/app/build.gradle.kts +++ b/apps/mobile/apps/staff/android/app/build.gradle.kts @@ -74,9 +74,10 @@ android { } buildTypes { + debug { + signingConfig = signingConfigs.getByName("release") + } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("release") } } diff --git a/apps/mobile/apps/staff/android/key.properties b/apps/mobile/apps/staff/android/key.properties index 94fa9453..6fcbc206 100644 --- a/apps/mobile/apps/staff/android/key.properties +++ b/apps/mobile/apps/staff/android/key.properties @@ -1,7 +1,7 @@ storePassword=krowwithus keyPassword=krowwithus keyAlias=krow_staff_dev -storeFile=app/krow_with_us_staff_dev.jks +storeFile=krow_with_us_staff_dev.jks ### ### Staff From ea77a4922401a97fa515d169232f98cf916f383b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 1 Mar 2026 22:29:15 -0500 Subject: [PATCH 38/38] feat: Remove commented-out code for commute tracker and attire photo section in ClockInPage --- .../src/presentation/pages/clock_in_page.dart | 224 +++++++++--------- .../presentation/pages/worker_home_page.dart | 12 - 2 files changed, 111 insertions(+), 125 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 8c56911a..94f8a0b5 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -89,18 +89,18 @@ class _ClockInPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Commute Tracker (shows before date selector when applicable) - if (selectedShift != null) - CommuteTracker( - shift: selectedShift, - hasLocationConsent: state.hasLocationConsent, - isCommuteModeOn: state.isCommuteModeOn, - distanceMeters: state.distanceFromVenue, - etaMinutes: state.etaMinutes, - onCommuteToggled: (bool value) { - _bloc.add(CommuteModeToggled(value)); - }, - ), + // // Commute Tracker (shows before date selector when applicable) + // if (selectedShift != null) + // CommuteTracker( + // shift: selectedShift, + // hasLocationConsent: state.hasLocationConsent, + // isCommuteModeOn: state.isCommuteModeOn, + // distanceMeters: state.distanceFromVenue, + // etaMinutes: state.etaMinutes, + // onCommuteToggled: (bool value) { + // _bloc.add(CommuteModeToggled(value)); + // }, + // ), // Date Selector DateSelector( selectedDate: state.selectedDate, @@ -256,111 +256,109 @@ class _ClockInPageState extends State { ) else ...[ // Attire Photo Section - if (!isCheckedIn) ...[ - Container( - padding: const EdgeInsets.all( - UiConstants.space4, - ), - margin: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusMd, - ), - child: const Icon( - UiIcons.camera, - color: UiColors.primary, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - i18n.attire_photo_label, - style: UiTypography.body2b, - ), - Text( - i18n.attire_photo_desc, - style: UiTypography - .body3r - .textSecondary, - ), - ], - ), - ), - UiButton.secondary( - text: i18n.take_attire_photo, - onPressed: () { - UiSnackbar.show( - context, - message: i18n.attire_captured, - type: UiSnackbarType.success, - ); - }, - ), - ], - ), - ), - ], - - if (!isCheckedIn && - (!state.isLocationVerified || - state.currentLocation == - null)) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all( - UiConstants.space4, - ), - margin: const EdgeInsets.only( - bottom: UiConstants.space4, - ), - decoration: BoxDecoration( - color: UiColors.tagError, - borderRadius: UiConstants.radiusLg, - ), - child: Row( - children: [ - const Icon( - UiIcons.error, - color: UiColors.textError, - size: 20, - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Text( - state.currentLocation == null - ? i18n.location_verifying - : i18n.not_in_range( - distance: '500', - ), - style: UiTypography.body3m.textError, - ), - ), - ], - ), - ), - ], + // if (!isCheckedIn) ...[ + // Container( + // padding: const EdgeInsets.all( + // UiConstants.space4, + // ), + // margin: const EdgeInsets.only( + // bottom: UiConstants.space4, + // ), + // decoration: BoxDecoration( + // color: UiColors.white, + // borderRadius: UiConstants.radiusLg, + // border: Border.all(color: UiColors.border), + // ), + // child: Row( + // children: [ + // Container( + // width: 48, + // height: 48, + // decoration: BoxDecoration( + // color: UiColors.bgSecondary, + // borderRadius: UiConstants.radiusMd, + // ), + // child: const Icon( + // UiIcons.camera, + // color: UiColors.primary, + // ), + // ), + // const SizedBox(width: UiConstants.space3), + // Expanded( + // child: Column( + // crossAxisAlignment: + // CrossAxisAlignment.start, + // children: [ + // Text( + // i18n.attire_photo_label, + // style: UiTypography.body2b, + // ), + // Text( + // i18n.attire_photo_desc, + // style: UiTypography + // .body3r + // .textSecondary, + // ), + // ], + // ), + // ), + // UiButton.secondary( + // text: i18n.take_attire_photo, + // onPressed: () { + // UiSnackbar.show( + // context, + // message: i18n.attire_captured, + // type: UiSnackbarType.success, + // ); + // }, + // ), + // ], + // ), + // ), + // ], + // if (!isCheckedIn && + // (!state.isLocationVerified || + // state.currentLocation == + // null)) ...[ + // Container( + // width: double.infinity, + // padding: const EdgeInsets.all( + // UiConstants.space4, + // ), + // margin: const EdgeInsets.only( + // bottom: UiConstants.space4, + // ), + // decoration: BoxDecoration( + // color: UiColors.tagError, + // borderRadius: UiConstants.radiusLg, + // ), + // child: Row( + // children: [ + // const Icon( + // UiIcons.error, + // color: UiColors.textError, + // size: 20, + // ), + // const SizedBox(width: UiConstants.space3), + // Expanded( + // child: Text( + // state.currentLocation == null + // ? i18n.location_verifying + // : i18n.not_in_range( + // distance: '500', + // ), + // style: UiTypography.body3m.textError, + // ), + // ), + // ], + // ), + // ), + // ], SwipeToCheckIn( isCheckedIn: isCheckedIn, mode: state.checkInMode, - isDisabled: - !isCheckedIn && !state.isLocationVerified, + isDisabled: isCheckedIn, isLoading: state.status == ClockInStatus.actionInProgress, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 1ba3ae29..d6ac2559 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -95,18 +95,6 @@ class WorkerHomePage extends StatelessWidget { return Column( children: [ - PlaceholderBanner( - title: bannersI18n.complete_profile_title, - subtitle: bannersI18n.complete_profile_subtitle, - bg: UiColors.primaryInverse, - accent: UiColors.primary, - onTap: () { - Modular.to.toProfile(); - }, - ), - - const SizedBox(height: UiConstants.space6), - // Quick Actions Row( mainAxisAlignment: MainAxisAlignment.spaceBetween,