diff --git a/PR_LOCALIZATION.md b/PR_LOCALIZATION.md new file mode 100644 index 00000000..06d9331d --- /dev/null +++ b/PR_LOCALIZATION.md @@ -0,0 +1,36 @@ +# PR: Localize all user-facing strings (#553) + +## Summary +All user-facing strings in the mobile apps have been localized. English and Spanish translations are provided via `core_localization` (Slang). + +## Changes + +### i18n (en.i18n.json & es.i18n.json) +- **common**: `file_not_found`, `error_occurred`, `gallery`, `camera`, `english`, `spanish` +- **staff_profile_attire.capture**: Attire capture flow (attest, validation, status, filters) +- **staff_certificates.upload_modal**: `name_hint`, `issuer_hint` +- **staff_documents**: `file_not_found`, `unknown` +- **staff_shifts.my_shift_card**: `checked_in`, `submit_for_approval`, `timesheet_submitted`, `submitted`, `ready_to_submit` +- **staff_shifts.shift_details**: `eligibility_requirements` +- **staff_shifts.shift_location**: `could_not_open_maps` +- **staff_shifts.my_shifts_tab**: `swap_coming_soon` +- **staff_clock_in**: `map_view_gps` +- **client_orders_common**: `select_vendor`, `hub`, `order_name`, `no_vendors`, `no_vendors_desc` +- **client_view_orders.order_edit_sheet**: Section headers and labels + +### Staff app +- **Attire**: `attire_capture_page`, `image_preview_section`, `attire_page` – all hardcoded strings replaced +- **Certificates**: `certificate_upload_page` – file validation, hint text +- **Documents**: `document_upload_page`, `documents_page` – file not found, unknown error +- **Shifts**: `my_shift_card` (status, timesheet), `my_shifts_tab` (swap message), `shift_location_section`, `shift_details_page` +- **Clock-in**: `location_map_placeholder` – Map View (GPS) + +### Client app +- **Orders**: `order_edit_sheet` – ORDER NAME, HUB, SELECT VENDOR section headers + +### Slang +- Ran `dart run slang` to regenerate i18n code + +## Verification +- Slang generation succeeds +- All affected Dart files use `context.t` or `Translations.of(context)` for user-facing strings 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 14d3e946..3d281bed 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 @@ -4,7 +4,13 @@ "cancel": "Cancel", "save": "Save", "delete": "Delete", - "continue_text": "Continue" + "continue_text": "Continue", + "error_occurred": "An error occurred", + "file_not_found": "File not found.", + "gallery": "Gallery", + "camera": "Camera", + "english": "English", + "spanish": "Español" }, "settings": { "language": "Language", @@ -209,6 +215,7 @@ "hubs": "Hubs", "log_out": "Log Out", "log_out_confirmation": "Are you sure you want to log out?", + "signed_out_successfully": "Signed out successfully", "quick_links": "Quick Links", "clock_in_hubs": "Clock-In Hubs", "billing_payments": "Billing & Payments" @@ -287,6 +294,7 @@ "edit_button": "Edit Hub", "deleted_success": "Hub deleted successfully" }, + "nfc_assigned_success": "NFC tag assigned successfully", "nfc_dialog": { "title": "Identify NFC Tag", "instruction": "Tap your phone to the NFC tag to identify it", @@ -303,6 +311,17 @@ "delete": "Delete" } }, + "client_orders_common": { + "select_vendor": "SELECT VENDOR", + "hub": "HUB", + "order_name": "ORDER NAME", + "permanent_days": "Permanent Days", + "recurring_days": "Recurring Days", + "start_date": "Start Date", + "end_date": "End Date", + "no_vendors": "No Vendors Available", + "no_vendors_desc": "There are no staffing vendors associated with your account." + }, "client_create_order": { "title": "Create Order", "section_title": "ORDER TYPE", @@ -324,6 +343,7 @@ "need_staff": "Need staff urgently?", "type_or_speak": "Type or speak what you need. I'll handle the rest", "example": "Example: ", + "placeholder_message": "Need 2 servers for a banquet right now.", "hint": "Type or speak... (e.g., \"Need 5 cooks ASAP until 5am\")", "speak": "Speak", "listening": "Listening...", @@ -463,6 +483,11 @@ } } }, + "client_billing_common": { + "invoices_ready": "Invoices Ready", + "total_amount": "TOTAL AMOUNT", + "no_invoices_ready": "No invoices ready yet" + }, "client_billing": { "title": "Billing", "current_period": "Current Period", @@ -595,6 +620,11 @@ "overview": { "title": "Your Benefits Overview", "subtitle": "Manage and track your earned benefits here", + "entitlement": "Entitlement", + "used": "Used", + "remaining": "Remaining", + "hours": "hours", + "empty_state": "No benefits available", "request_payment": "Request Payment for $benefit", "request_submitted": "Request submitted for $benefit", "sick_leave_subtitle": "You need at least 8 hours to request sick leave", @@ -844,6 +874,7 @@ "checkout_complete": "Check Out!", "checkin_complete": "Check In!" }, + "map_view_gps": "Map View (GPS)", "lunch_break": { "title": "Did You Take\na Lunch?", "no": "No", @@ -1041,7 +1072,8 @@ }, "list": { "empty": "No documents found", - "error": "Error: $message" + "error": "Error: $message", + "unknown": "Unknown" }, "card": { "view": "View", @@ -1053,6 +1085,8 @@ }, "upload": { "instructions": "Please select a valid PDF file to upload.", + "pdf_banner": "Only PDF files are accepted. Maximum file size is 10MB.", + "file_not_found": "File not found.", "submit": "Submit Document", "select_pdf": "Select PDF File", "attestation": "I certify that this document is genuine and valid.", @@ -1088,12 +1122,14 @@ "upload_modal": { "title": "Upload Certificate", "name_label": "Certificate Name", + "name_hint": "e.g. Food Handler Permit", "issuer_label": "Certificate Issuer", + "issuer_hint": "e.g. Department of Health", "expiry_label": "Expiration Date (Optional)", "select_date": "Select date", "upload_file": "Upload File", "drag_drop": "Drag and drop or click to upload", - "supported_formats": "PDF, JPG, PNG up to 10MB", + "supported_formats": "PDF up to 10MB", "cancel": "Cancel", "save": "Save Certificate", "success_snackbar": "Certificate successfully uploaded and pending verification" @@ -1125,6 +1161,22 @@ "select_required": "\u2713 Select all required items", "upload_required": "\u2713 Upload photos of required items", "accept_attestation": "\u2713 Accept attestation" + }, + "upload_file_types_banner": "Only JPEG, JPG, and PNG files are accepted. Maximum file size is 10MB.", + "capture": { + "attest_please": "Please attest that you own this item.", + "could_not_access_media": "Could not access camera or gallery. Please try again.", + "attire_submitted": "Attire image submitted for verification", + "file_size_exceeds": "File size exceeds 10MB. Maximum file size is 10MB.", + "pending_verification": "Pending Verification", + "not_uploaded": "Not Uploaded", + "your_uploaded_photo": "Your Uploaded Photo", + "reference_example": "Reference Example", + "review_attire_item": "Review the attire item", + "example_upload_hint": "Example of the item that you need to upload.", + "no_items_filter": "No items found for this filter.", + "approved": "Approved", + "rejected": "Rejected" } }, "staff_shifts": { @@ -1209,7 +1261,21 @@ }, "applying_dialog": { "title": "Applying" - } + }, + "eligibility_requirements": "Eligibility Requirements" + }, + "my_shift_card": { + "submit_for_approval": "Submit for Approval", + "timesheet_submitted": "Timesheet submitted for client approval", + "checked_in": "Checked in", + "submitted": "SUBMITTED", + "ready_to_submit": "READY TO SUBMIT" + }, + "shift_location": { + "could_not_open_maps": "Could not open maps" + }, + "history_tab": { + "subtitle": "Completed shifts appear here" }, "card": { "just_now": "Just now", @@ -1218,6 +1284,7 @@ "decline_shift": "Decline shift" }, "my_shifts_tab": { + "swap_coming_soon": "Swap functionality coming soon!", "confirm_dialog": { "title": "Accept Shift", "message": "Are you sure you want to accept this shift?", @@ -1247,6 +1314,9 @@ } }, "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_cta": "Complete your account now", "search_hint": "Search jobs, location...", "filter_all": "All Jobs", "filter_one_day": "One Day", @@ -1585,12 +1655,30 @@ } }, "client_coverage": { + "todays_status": "Today's Status", + "unfilled_today": "Unfilled Today", + "running_late": "Running Late", + "checked_in": "Checked In", + "todays_cost": "Today's Cost", + "no_shifts_day": "No shifts scheduled for this day", + "no_workers_assigned": "No workers assigned yet", "worker_row": { "verify": "Verify", "verified_message": "Worker attire verified for $name" } }, + "client_reports_common": { + "export_coming_soon": "Export coming soon" + }, + "client_authentication_demo": { + "shift_order_placeholder": "Shift Order #824", + "worker_name_placeholder": "Alex Thompson" + }, "staff_payments": { + "bank_placeholder": "Chase Bank", + "ending_in": "Ending in 4321", + "this_week": "This Week", + "this_month": "This Month", "early_pay": { "title": "Early Pay", "available_label": "Available for Cash Out", 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 5db8cb8a..8b8fef08 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 @@ -4,7 +4,13 @@ "cancel": "Cancelar", "save": "Guardar", "delete": "Eliminar", - "continue_text": "Continuar" + "continue_text": "Continuar", + "error_occurred": "Ocurrió un error", + "file_not_found": "Archivo no encontrado.", + "gallery": "Galería", + "camera": "Cámara", + "english": "English", + "spanish": "Español" }, "settings": { "language": "Idioma", @@ -209,6 +215,7 @@ "hubs": "Hubs", "log_out": "Cerrar sesi\u00f3n", "log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?", + "signed_out_successfully": "Sesi\u00f3n cerrada correctamente", "quick_links": "Enlaces r\u00e1pidos", "clock_in_hubs": "Hubs de Marcaje", "billing_payments": "Facturaci\u00f3n y Pagos" @@ -301,7 +308,19 @@ "cost_center_label": "Centro de Costos", "cost_center_none": "No asignado", "deleted_success": "Hub eliminado exitosamente" - } + }, + "nfc_assigned_success": "Etiqueta NFC asignada exitosamente" + }, + "client_orders_common": { + "select_vendor": "SELECCIONAR PROVEEDOR", + "hub": "HUB", + "order_name": "NOMBRE DE ORDEN", + "permanent_days": "Días Permanentes", + "recurring_days": "Días Recurrentes", + "start_date": "Fecha de Inicio", + "end_date": "Fecha de Fin", + "no_vendors": "No Hay Proveedores Disponibles", + "no_vendors_desc": "No hay proveedores de personal asociados a tu cuenta." }, "client_create_order": { "title": "Crear Orden", @@ -324,6 +343,7 @@ "need_staff": "\u00bfNecesitas personal urgentemente?", "type_or_speak": "Escribe o habla lo que necesitas. Yo me encargo del resto", "example": "Ejemplo: ", + "placeholder_message": "Necesito 2 meseros para un banquete ahora mismo.", "hint": "Escribe o habla... (ej., \"Necesito 5 cocineros YA hasta las 5am\")", "speak": "Hablar", "listening": "Escuchando...", @@ -595,6 +615,11 @@ "overview": { "title": "Resumen de tus Beneficios", "subtitle": "Gestiona y sigue tus beneficios ganados aqu\u00ed", + "entitlement": "Derecho", + "used": "Usado", + "remaining": "Disponible", + "hours": "horas", + "empty_state": "No hay beneficios disponibles", "request_payment": "Solicitar pago por $benefit", "request_submitted": "Solicitud enviada para $benefit", "sick_leave_subtitle": "Necesitas al menos 8 horas para solicitar d\u00edas de enfermedad", @@ -834,6 +859,7 @@ "arrived_title": "\u00a1Has llegado! \ud83c\udf89", "arrived_desc": "Est\u00e1s en el lugar del turno. \u00bfListo para registrar tu entrada?" }, + "map_view_gps": "Vista de Mapa (GPS)", "swipe": { "checking_out": "Registrando salida...", "checking_in": "Registrando entrada...", @@ -1041,7 +1067,8 @@ }, "list": { "empty": "No se encontraron documentos", - "error": "Error: $message" + "error": "Error: $message", + "unknown": "Desconocido" }, "card": { "view": "Ver", @@ -1053,12 +1080,14 @@ }, "upload": { "instructions": "Por favor selecciona un archivo PDF válido para subir.", + "pdf_banner": "Solo se aceptan archivos PDF. Tamaño máximo del archivo: 10MB.", "submit": "Enviar Documento", "select_pdf": "Seleccionar Archivo PDF", "attestation": "Certifico que este documento es genuino y válido.", "success": "Documento subido exitosamente", "error": "Error al subir el documento", - "replace": "Reemplazar" + "replace": "Reemplazar", + "file_not_found": "Archivo no encontrado." } }, "staff_certificates": { @@ -1093,7 +1122,9 @@ "select_date": "Seleccionar fecha", "upload_file": "Subir Archivo", "drag_drop": "Arrastra y suelta o haz clic para subir", - "supported_formats": "PDF, JPG, PNG hasta 10MB", + "supported_formats": "PDF hasta 10MB", + "name_hint": "ej. Permiso de Manipulación de Alimentos", + "issuer_hint": "ej. Departamento de Salud", "cancel": "Cancelar", "save": "Guardar Certificado", "success_snackbar": "Certificado subido exitosamente y pendiente de verificaci\u00f3n" @@ -1125,6 +1156,22 @@ "select_required": "\u2713 Seleccionar todos los art\u00edculos requeridos", "upload_required": "\u2713 Subir fotos de art\u00edculos requeridos", "accept_attestation": "\u2713 Aceptar certificaci\u00f3n" + }, + "upload_file_types_banner": "Solo se aceptan archivos JPEG, JPG y PNG. Tamaño máximo del archivo: 10MB.", + "capture": { + "attest_please": "Por favor, certifica que posees este artículo.", + "could_not_access_media": "No se pudo acceder a la cámara o galería. Por favor, inténtalo de nuevo.", + "attire_submitted": "Imagen de vestimenta enviada para verificación", + "file_size_exceeds": "El tamaño del archivo supera 10MB. Tamaño máximo: 10MB.", + "pending_verification": "Verificación Pendiente", + "not_uploaded": "No Subido", + "your_uploaded_photo": "Tu Foto Subida", + "reference_example": "Ejemplo de Referencia", + "review_attire_item": "Revisar el artículo de vestimenta", + "example_upload_hint": "Ejemplo del artículo que debes subir.", + "no_items_filter": "No se encontraron artículos para este filtro.", + "approved": "Aprobado", + "rejected": "Rechazado" } }, "staff_shifts": { @@ -1209,7 +1256,21 @@ }, "applying_dialog": { "title": "Solicitando" - } + }, + "eligibility_requirements": "Requisitos de Elegibilidad" + }, + "my_shift_card": { + "submit_for_approval": "Enviar para Aprobación", + "timesheet_submitted": "Hoja de tiempo enviada para aprobación del cliente", + "checked_in": "Registrado", + "submitted": "ENVIADO", + "ready_to_submit": "LISTO PARA ENVIAR" + }, + "shift_location": { + "could_not_open_maps": "No se pudo abrir mapas" + }, + "history_tab": { + "subtitle": "Los turnos completados aparecen aquí" }, "card": { "just_now": "Reci\u00e9n", @@ -1218,6 +1279,7 @@ "decline_shift": "Rechazar turno" }, "my_shifts_tab": { + "swap_coming_soon": "¡Función de intercambio próximamente!", "confirm_dialog": { "title": "Aceptar Turno", "message": "\u00bfEst\u00e1s seguro de que quieres aceptar este turno?", @@ -1247,6 +1309,9 @@ } }, "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_cta": "Completa tu cuenta ahora", "search_hint": "Buscar trabajos, ubicaci\u00f3n...", "filter_all": "Todos", "filter_one_day": "Un d\u00eda", @@ -1584,13 +1649,36 @@ } } }, + "client_billing_common": { + "invoices_ready": "Facturas Listas", + "total_amount": "MONTO TOTAL", + "no_invoices_ready": "Aún no hay facturas listas" + }, "client_coverage": { + "todays_status": "Estado de Hoy", + "unfilled_today": "Sin Cubrir Hoy", + "running_late": "Llegando Tarde", + "checked_in": "Registrado", + "todays_cost": "Costo de Hoy", + "no_shifts_day": "No hay turnos programados para este día", + "no_workers_assigned": "Aún no hay trabajadores asignados", "worker_row": { "verify": "Verificar", "verified_message": "Vestimenta del trabajador verificada para $name" } }, + "client_reports_common": { + "export_coming_soon": "Exportar próximamente" + }, + "client_authentication_demo": { + "shift_order_placeholder": "Orden de Turno #824", + "worker_name_placeholder": "Alex Thompson" + }, "staff_payments": { + "bank_placeholder": "Chase Bank", + "ending_in": "Terminando en 4321", + "this_week": "Esta Semana", + "this_month": "Este Mes", "early_pay": { "title": "Pago Anticipado", "available_label": "Disponible para Retirar", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 60e1ebe7..5e3e7125 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,4 +1,4 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; @@ -198,11 +198,16 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return response.data.benefitsDatas .map( - (dc.ListBenefitsDataByStaffIdBenefitsDatas e) => domain.Benefit( - title: e.vendorBenefitPlan.title, - entitlementHours: e.vendorBenefitPlan.total?.toDouble() ?? 0, - usedHours: e.current.toDouble(), - ), + (dc.ListBenefitsDataByStaffIdBenefitsDatas e) { + final total = + e.vendorBenefitPlan.total?.toDouble() ?? 0.0; + final remaining = e.current.toDouble(); + return domain.Benefit( + title: e.vendorBenefitPlan.title, + entitlementHours: total, + usedHours: (total - remaining).clamp(0.0, total), + ); + }, ) .toList(); }); diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index e0e79a28..87b9f240 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -73,11 +73,15 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title; + final order = shiftRole.shift.order; + final String? hubManagerId = order.hubManagerId; + final String? hubManagerName = order.hubManager?.user?.fullName; + return domain.OrderItem( id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), - orderId: shiftRole.shift.order.id, + orderId: order.id, orderType: domain.OrderType.fromString( - shiftRole.shift.order.orderType.stringValue, + order.orderType.stringValue, ), title: shiftRole.role.name, eventName: eventName, @@ -94,6 +98,8 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository { hours: hours, totalValue: totalValue, confirmedApps: const >[], + hubManagerId: hubManagerId, + hubManagerName: hubManagerName, ); }).toList(); }); diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart index 3ea97a5e..3a8a1bfb 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart @@ -193,6 +193,8 @@ class ViewOrdersCubit extends Cubit hours: order.hours, totalValue: order.totalValue, confirmedApps: confirmed, + hubManagerId: order.hubManagerId, + hubManagerName: order.hubManagerName, ); }).toList(); } diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index a8cd6843..db2d1ed6 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -692,7 +692,7 @@ class OrderEditSheetState extends State { ), const SizedBox(height: UiConstants.space4), - _buildSectionHeader('VENDOR'), + _buildSectionHeader(t.client_orders_common.select_vendor), Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space3, @@ -742,7 +742,7 @@ class OrderEditSheetState extends State { ), const SizedBox(height: UiConstants.space4), - _buildSectionHeader('ORDER NAME'), + _buildSectionHeader(t.client_orders_common.order_name), UiTextField( controller: _orderNameController, hintText: t.client_view_orders.order_edit_sheet.order_name_hint, @@ -750,7 +750,7 @@ class OrderEditSheetState extends State { ), const SizedBox(height: UiConstants.space4), - _buildSectionHeader('HUB'), + _buildSectionHeader(t.client_orders_common.hub), Container( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space3, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index b5f02c97..689ec491 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -108,6 +108,43 @@ class _ViewOrderCardState extends State { } } + /// Returns true if the edit icon should be shown. + /// Hidden for completed orders and for past orders (shift has ended). + bool _canEditOrder(OrderItem order) { + if (order.status == 'COMPLETED') return false; + if (order.date.isEmpty) return true; + try { + final DateTime orderDate = DateTime.parse(order.date); + final String endTime = order.endTime.trim(); + final DateTime endDateTime; + if (endTime.isEmpty) { + // No end time: use end of day so orders today remain editable + endDateTime = DateTime( + orderDate.year, + orderDate.month, + orderDate.day, + 23, + 59, + 59, + ); + } else { + final List endParts = endTime.split(':'); + final int hour = endParts.isNotEmpty ? int.parse(endParts[0]) : 0; + final int minute = endParts.length > 1 ? int.parse(endParts[1]) : 0; + endDateTime = DateTime( + orderDate.year, + orderDate.month, + orderDate.day, + hour, + minute, + ); + } + return endDateTime.isAfter(DateTime.now()); + } catch (_) { + return true; + } + } + @override Widget build(BuildContext context) { final OrderItem order = widget.order; @@ -291,13 +328,14 @@ class _ViewOrderCardState extends State { // Actions Row( children: [ - _buildHeaderIconButton( - icon: UiIcons.edit, - color: UiColors.primary, - bgColor: UiColors.primary.withValues(alpha: 0.08), - onTap: () => _openEditSheet(order: order), - ), - const SizedBox(width: UiConstants.space2), + if (_canEditOrder(order)) + _buildHeaderIconButton( + icon: UiIcons.edit, + color: UiColors.primary, + bgColor: UiColors.primary.withValues(alpha: 0.08), + onTap: () => _openEditSheet(order: order), + ), + if (_canEditOrder(order)) const SizedBox(width: UiConstants.space2), if (order.confirmedApps.isNotEmpty) _buildHeaderIconButton( icon: _expanded diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/utils/test_phone_numbers.dart b/apps/mobile/packages/features/staff/authentication/lib/src/utils/test_phone_numbers.dart index c5e8fb00..f2a9569e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/utils/test_phone_numbers.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/utils/test_phone_numbers.dart @@ -1,6 +1,8 @@ class TestPhoneNumbers { static const List values = [ - '+15145912311', // Test User 1 + '+15145912311', // Test User 1 + '+15557654321', // Demo / Mariana + '+15555551234', // Test User 2 ]; static bool isTestNumber(String phoneNumber) { diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart index 803d9f7a..16075931 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/location_map_placeholder.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -42,7 +43,7 @@ class LocationMapPlaceholder extends StatelessWidget { color: UiColors.iconSecondary, ), const SizedBox(height: UiConstants.space2), - Text('Map View (GPS)', style: UiTypography.body2r.textSecondary), + Text(context.t.staff.clock_in.map_view_gps, style: UiTypography.body2r.textSecondary), ], ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index a567b5e9..118c66e5 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -123,10 +123,12 @@ class HomeRepositoryImpl return response.data.benefitsDatas.map((data) { final plan = data.vendorBenefitPlan; + final total = plan.total?.toDouble() ?? 0.0; + final remaining = data.current.toDouble(); return Benefit( title: plan.title, - entitlementHours: plan.total?.toDouble() ?? 0.0, - usedHours: data.current.toDouble(), + entitlementHours: total, + usedHours: (total - remaining).clamp(0.0, total), ); }).toList(); }); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index 271bc46c..0fc38f98 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -46,7 +46,7 @@ class BenefitsOverviewPage extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(UiConstants.space6), child: Text( - t.staff.home.benefits.overview.subtitle, + t.staff.home.benefits.overview.empty_state, style: UiTypography.body1r.textSecondary, textAlign: TextAlign.center, ), @@ -143,21 +143,17 @@ class BenefitsOverviewPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - benefit.title, - style: UiTypography.body1b.textPrimary, - ), - const Icon(UiIcons.info, size: 18, color: Color(0xFFE2E8F0)), - ], + Text( + benefit.title, + style: UiTypography.body1b.textPrimary, ), const SizedBox(height: 4), Text( _getSubtitle(benefit.title), style: UiTypography.footnote2r.textSecondary, ), + const SizedBox(height: UiConstants.space4), + _buildStatsRow(), ], ), ), @@ -231,6 +227,56 @@ class BenefitsOverviewPage extends StatelessWidget { ); } + Widget _buildStatsRow() { + final i18n = t.staff.home.benefits.overview; + return Row( + children: [ + _buildStatChip( + i18n.entitlement, + '${benefit.entitlementHours.toInt()}', + ), + const SizedBox(width: 8), + _buildStatChip( + i18n.used, + '${benefit.usedHours.toInt()}', + ), + const SizedBox(width: 8), + _buildStatChip( + i18n.remaining, + '${benefit.remainingHours.toInt()}', + ), + ], + ); + } + + Widget _buildStatChip(String label, String value) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: UiTypography.footnote2r.textTertiary.copyWith( + fontSize: 10, + ), + ), + Text( + '$value ${t.staff.home.benefits.overview.hours}', + style: UiTypography.footnote2b.textPrimary.copyWith( + fontSize: 12, + ), + ), + ], + ), + ); + } + String _getSubtitle(String title) { final i18n = t.staff.home.benefits.overview; if (title.toLowerCase().contains('sick')) { 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 6f9da08e..8a896e9e 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,3 +1,5 @@ +import 'dart:io'; + import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -55,18 +57,44 @@ class _CertificateUploadPageState extends State { super.dispose(); } + static const int _kMaxFileSizeBytes = 10 * 1024 * 1024; + Future _pickFile() async { final String? path = await _filePicker.pickFile( - allowedExtensions: ['pdf', 'jpg', 'png'], + allowedExtensions: ['pdf'], ); if (path != null) { + final String? error = _validatePdfFile(context, path); + if (error != null && mounted) { + 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; + } + Future _selectDate() async { final DateTime? picked = await showDatePicker( context: context, @@ -117,12 +145,8 @@ class _CertificateUploadPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - t - .staff_documents - .upload - .instructions, // Reusing instructions logic - style: UiTypography.body1m.textPrimary, + _PdfFileTypesBanner( + message: t.staff_documents.upload.pdf_banner, ), const SizedBox(height: UiConstants.space6), @@ -135,7 +159,7 @@ class _CertificateUploadPageState extends State { TextField( controller: _nameController, decoration: InputDecoration( - hintText: "e.g. Food Handler Permit", + hintText: t.staff_certificates.upload_modal.name_hint, border: OutlineInputBorder( borderRadius: UiConstants.radiusLg, ), @@ -193,7 +217,7 @@ class _CertificateUploadPageState extends State { TextField( controller: _issuerController, decoration: InputDecoration( - hintText: "e.g. Department of Health", + hintText: t.staff_certificates.upload_modal.issuer_hint, border: OutlineInputBorder( borderRadius: UiConstants.radiusLg, ), @@ -247,19 +271,33 @@ class _CertificateUploadPageState extends State { (_selectedFilePath != null && state.isAttested && _nameController.text.isNotEmpty) - ? () => - BlocProvider.of( + ? () { + final String? err = + _validatePdfFile(context, _selectedFilePath!); + if (err != null) { + UiSnackbar.show( context, - ).uploadCertificate( - UploadCertificateParams( - certificationType: _selectedType!, - name: _nameController.text, - filePath: _selectedFilePath!, - expiryDate: _selectedExpiryDate, - issuer: _issuerController.text, - certificateNumber: _numberController.text, + 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, @@ -291,6 +329,42 @@ 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.tagActive, + 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}); 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 975fee10..5546c244 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,3 +1,5 @@ +import 'dart:io'; + import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -37,18 +39,44 @@ 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 (path != null) { + final String? error = _validatePdfFile(context, path); + if (error != null && mounted) { + 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) { return BlocProvider( @@ -82,9 +110,8 @@ class _DocumentUploadPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - t.staff_documents.upload.instructions, - style: UiTypography.body1m.textPrimary, + _PdfFileTypesBanner( + message: t.staff_documents.upload.pdf_banner, ), const SizedBox(height: UiConstants.space6), DocumentFileSelector( @@ -116,9 +143,22 @@ class _DocumentUploadPageState extends State { isUploading: state.status == DocumentUploadStatus.uploading, canSubmit: _selectedFilePath != null && state.isAttested, - onSubmit: () => BlocProvider.of( - context, - ).uploadDocument(widget.document.id, _selectedFilePath!), + 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(widget.document.id, _selectedFilePath!); + }, ), ], ), @@ -130,3 +170,39 @@ 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.tagActive, + 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, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 884aad75..1f84c79f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -45,7 +45,7 @@ class DocumentsPage extends StatelessWidget { : t.staff_documents.list.error( message: state.errorMessage!, )) - : t.staff_documents.list.error(message: 'Unknown'), + : t.staff_documents.list.error(message: t.staff_documents.list.unknown), textAlign: TextAlign.center, style: UiTypography.body1m.copyWith( color: UiColors.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 82109743..6c4e7880 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 @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -31,6 +33,12 @@ class AttireCapturePage extends StatefulWidget { State createState() => _AttireCapturePageState(); } +/// Maximum file size for attire upload (10MB). +const int _kMaxFileSizeBytes = 10 * 1024 * 1024; + +/// Allowed file extensions for attire upload. +const Set _kAllowedExtensions = {'jpeg', 'jpg', 'png'}; + class _AttireCapturePageState extends State { String? _selectedLocalPath; @@ -57,6 +65,16 @@ class _AttireCapturePageState extends State { final GalleryService service = Modular.get(); final String? path = await service.pickImage(); if (path != null && context.mounted) { + final String? error = _validateFile(path); + if (error != null) { + UiSnackbar.show( + context, + message: error, + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } setState(() { _selectedLocalPath = path; }); @@ -84,6 +102,16 @@ class _AttireCapturePageState extends State { final CameraService service = Modular.get(); final String? path = await service.takePhoto(); if (path != null && context.mounted) { + final String? error = _validateFile(path); + if (error != null) { + UiSnackbar.show( + context, + message: error, + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } setState(() { _selectedLocalPath = path; }); @@ -105,7 +133,7 @@ class _AttireCapturePageState extends State { children: [ ListTile( leading: const Icon(Icons.photo_library), - title: const Text('Gallery'), + title: Text(t.common.gallery), onTap: () { Modular.to.pop(); _onGallery(context); @@ -113,7 +141,7 @@ class _AttireCapturePageState extends State { ), ListTile( leading: const Icon(Icons.camera_alt), - title: const Text('Camera'), + title: Text(t.common.camera), onTap: () { Modular.to.pop(); _onCamera(context); @@ -128,17 +156,33 @@ class _AttireCapturePageState extends State { void _showAttestationWarning(BuildContext context) { UiSnackbar.show( context, - message: 'Please attest that you own this item.', + message: t.staff_profile_attire.capture.attest_please, type: UiSnackbarType.error, margin: const EdgeInsets.all(UiConstants.space4), ); } + /// Validates file format (JPEG, JPG, PNG) and size (max 10MB). + /// Returns an error message if invalid, or null if valid. + String? _validateFile(String path) { + final File file = File(path); + if (!file.existsSync()) return t.common.file_not_found; + final String ext = path.split('.').last.toLowerCase(); + if (!_kAllowedExtensions.contains(ext)) { + return t.staff_profile_attire.upload_file_types_banner; + } + final int size = file.lengthSync(); + if (size > _kMaxFileSizeBytes) { + return t.staff_profile_attire.capture.file_size_exceeds; + } + return null; + } + void _showError(BuildContext context, String message) { debugPrint(message); UiSnackbar.show( context, - message: 'Could not access camera or gallery. Please try again.', + message: t.staff_profile_attire.capture.could_not_access_media, type: UiSnackbarType.error, margin: const EdgeInsets.all(UiConstants.space4), ); @@ -150,6 +194,17 @@ class _AttireCapturePageState extends State { ); if (_selectedLocalPath == null) return; + final String? error = _validateFile(_selectedLocalPath!); + if (error != null) { + UiSnackbar.show( + context, + message: error, + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } + await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!); if (context.mounted && cubit.state.status == AttireCaptureStatus.success) { setState(() { @@ -160,10 +215,10 @@ class _AttireCapturePageState extends State { String _getStatusText(bool hasUploadedPhoto) { return switch (widget.item.verificationStatus) { - AttireVerificationStatus.approved => 'Approved', - AttireVerificationStatus.rejected => 'Rejected', - AttireVerificationStatus.pending => 'Pending Verification', - _ => hasUploadedPhoto ? 'Pending Verification' : '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, }; } @@ -207,7 +262,7 @@ class _AttireCapturePageState extends State { if (state.status == AttireCaptureStatus.success) { UiSnackbar.show( context, - message: 'Attire image submitted for verification', + message: t.staff_profile_attire.capture.attire_submitted, type: UiSnackbarType.success, ); Modular.to.toAttire(); @@ -225,6 +280,10 @@ class _AttireCapturePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( children: [ + _FileTypesBanner( + message: t.staff_profile_attire.upload_file_types_banner, + ), + const SizedBox(height: UiConstants.space4), ImagePreviewSection( selectedLocalPath: _selectedLocalPath, currentPhotoUrl: currentPhotoUrl, @@ -268,3 +327,43 @@ 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.tagActive, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index 280fd344..989033ab 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -78,7 +78,7 @@ class AttirePage extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No items found for this filter.', + context.t.staff_profile_attire.capture.no_items_filter, style: UiTypography.body1m.textSecondary, ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart index 18a6e930..b2d4a050 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -28,7 +29,7 @@ class ImagePreviewSection extends StatelessWidget { return Column( children: [ Text( - 'Review the attire item', + context.t.staff_profile_attire.capture.review_attire_item, style: UiTypography.body1b.textPrimary, ), const SizedBox(height: UiConstants.space2), @@ -42,7 +43,7 @@ class ImagePreviewSection extends StatelessWidget { if (currentPhotoUrl != null) { return Column( children: [ - Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary), + Text(context.t.staff_profile_attire.capture.your_uploaded_photo, style: UiTypography.body1b.textPrimary), const SizedBox(height: UiConstants.space2), AttireImagePreview(imageUrl: currentPhotoUrl), const SizedBox(height: UiConstants.space4), @@ -56,7 +57,7 @@ class ImagePreviewSection extends StatelessWidget { AttireImagePreview(imageUrl: referenceImageUrl), const SizedBox(height: UiConstants.space4), Text( - 'Example of the item that you need to upload.', + context.t.staff_profile_attire.capture.example_upload_hint, style: UiTypography.body1b.textSecondary, textAlign: TextAlign.center, ), @@ -77,7 +78,7 @@ class ReferenceExample extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Text('Reference Example', style: UiTypography.body2b.textSecondary), + Text(context.t.staff_profile_attire.capture.reference_example, style: UiTypography.body2b.textSecondary), const SizedBox(height: UiConstants.space1), Center( child: ClipRRect( 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 7500eca6..2fe89c89 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 @@ -317,7 +317,7 @@ class _ShiftDetailsPageState extends State { children: [ const Icon(UiIcons.warning, color: UiColors.error), const SizedBox(width: UiConstants.space2), - Expanded(child: Text("Eligibility Requirements")), + Expanded(child: Text(context.t.staff_shifts.shift_details.eligibility_requirements)), ], ), content: Text( diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index b515c21f..f4a2f216 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -252,7 +252,10 @@ class _ShiftsPageState extends State { if (availableLoading) { return const Center(child: CircularProgressIndicator()); } - return FindShiftsTab(availableJobs: availableJobs); + return FindShiftsTab( + availableJobs: availableJobs, + profileComplete: state.profileComplete ?? true, + ); case ShiftTabType.history: if (historyLoading) { return const Center(child: CircularProgressIndicator()); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index 54e82f80..ad898f00 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -117,7 +117,7 @@ class _MyShiftCardState extends State { statusColor = UiColors.textLink; statusBg = UiColors.primary; } else if (status == 'checked_in') { - statusText = 'Checked in'; + statusText = context.t.staff_shifts.my_shift_card.checked_in; statusColor = UiColors.textSuccess; statusBg = UiColors.iconSuccess; } else if (status == 'pending' || status == 'open') { @@ -487,20 +487,22 @@ class _MyShiftCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _isSubmitted ? 'SUBMITTED' : 'READY TO SUBMIT', + _isSubmitted + ? context.t.staff_shifts.my_shift_card.submitted + : context.t.staff_shifts.my_shift_card.ready_to_submit, style: UiTypography.footnote2b.copyWith( color: _isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, ), ), if (!_isSubmitted) UiButton.secondary( - text: 'Submit for Approval', + text: context.t.staff_shifts.my_shift_card.submit_for_approval, size: UiButtonSize.small, onPressed: () { setState(() => _isSubmitted = true); UiSnackbar.show( context, - message: 'Timesheet submitted for client approval', + message: context.t.staff_shifts.my_shift_card.timesheet_submitted, type: UiSnackbarType.success, ); }, diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart index dad82cd2..c9d557cb 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -124,7 +125,7 @@ class ShiftLocationSection extends StatelessWidget { if (context.mounted) { UiSnackbar.show( context, - message: 'Could not open maps', + message: context.t.staff_shifts.shift_location.could_not_open_maps, type: UiSnackbarType.error, ); } 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 f715ee6c..2aed6460 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,5 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:core_localization/core_localization.dart'; @@ -12,7 +13,15 @@ import 'package:geolocator/geolocator.dart'; class FindShiftsTab extends StatefulWidget { final List availableJobs; - const FindShiftsTab({super.key, required this.availableJobs}); + /// Whether the worker's profile is complete. When false, shows incomplete + /// profile banner and disables apply actions. + final bool profileComplete; + + const FindShiftsTab({ + super.key, + required this.availableJobs, + this.profileComplete = true, + }); @override State createState() => _FindShiftsTabState(); @@ -305,6 +314,15 @@ class _FindShiftsTabState extends State { return Column( 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(), + ), + ], // Search and Filters Container( color: UiColors.white, @@ -440,20 +458,22 @@ class _FindShiftsTabState extends State { ), child: MyShiftCard( shift: shift, - onAccept: () { - context.read().add( - AcceptShiftEvent(shift.id), - ); - UiSnackbar.show( - context, - message: context - .t - .staff_shifts - .find_shifts - .application_submitted, - type: UiSnackbarType.success, - ); - }, + onAccept: widget.profileComplete + ? () { + context.read().add( + AcceptShiftEvent(shift.id), + ); + UiSnackbar.show( + context, + message: context + .t + .staff_shifts + .find_shifts + .application_submitted, + type: UiSnackbarType.success, + ); + } + : null, ), ), ), @@ -466,3 +486,54 @@ 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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index 51472ec9..1075b341 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -104,28 +104,28 @@ class _MyShiftsTabState extends State { void _confirmShift(String id) { showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(t.staff_shifts.my_shifts_tab.confirm_dialog.title), - content: Text(t.staff_shifts.my_shifts_tab.confirm_dialog.message), + builder: (ctx) => AlertDialog( + title: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), + content: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.common.cancel), + onPressed: () => Navigator.of(ctx).pop(), + child: Text(context.t.common.cancel), ), TextButton( onPressed: () { - Navigator.of(context).pop(); + Navigator.of(ctx).pop(); context.read().add(AcceptShiftEvent(id)); UiSnackbar.show( context, - message: t.staff_shifts.my_shifts_tab.confirm_dialog.success, + message: context.t.staff_shifts.my_shifts_tab.confirm_dialog.success, type: UiSnackbarType.success, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.success, ), - child: Text(t.staff_shifts.shift_details.accept_shift), + child: Text(context.t.staff_shifts.shift_details.accept_shift), ), ], ), @@ -135,30 +135,30 @@ class _MyShiftsTabState extends State { void _declineShift(String id) { showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(t.staff_shifts.my_shifts_tab.decline_dialog.title), + builder: (ctx) => AlertDialog( + title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), content: Text( - t.staff_shifts.my_shifts_tab.decline_dialog.message, + context.t.staff_shifts.my_shifts_tab.decline_dialog.message, ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.common.cancel), + onPressed: () => Navigator.of(ctx).pop(), + child: Text(context.t.common.cancel), ), TextButton( onPressed: () { - Navigator.of(context).pop(); + Navigator.of(ctx).pop(); context.read().add(DeclineShiftEvent(id)); UiSnackbar.show( context, - message: t.staff_shifts.my_shifts_tab.decline_dialog.success, + message: context.t.staff_shifts.my_shifts_tab.decline_dialog.success, type: UiSnackbarType.error, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: Text(t.staff_shifts.shift_details.decline), + child: Text(context.t.staff_shifts.shift_details.decline), ), ], ), @@ -169,9 +169,9 @@ class _MyShiftsTabState extends State { try { final date = DateTime.parse(dateStr); final now = DateTime.now(); - if (_isSameDay(date, now)) return t.staff_shifts.my_shifts_tab.date.today; + if (_isSameDay(date, now)) return context.t.staff_shifts.my_shifts_tab.date.today; final tomorrow = now.add(const Duration(days: 1)); - if (_isSameDay(date, tomorrow)) return t.staff_shifts.my_shifts_tab.date.tomorrow; + if (_isSameDay(date, tomorrow)) return context.t.staff_shifts.my_shifts_tab.date.tomorrow; return DateFormat('EEE, MMM d').format(date); } catch (_) { return dateStr; @@ -338,7 +338,7 @@ class _MyShiftsTabState extends State { const SizedBox(height: UiConstants.space5), if (widget.pendingAssignments.isNotEmpty) ...[ _buildSectionHeader( - t.staff_shifts.my_shifts_tab.sections.awaiting, + context.t.staff_shifts.my_shifts_tab.sections.awaiting, UiColors.textWarning, ), ...widget.pendingAssignments.map( @@ -356,7 +356,7 @@ class _MyShiftsTabState extends State { ], if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader(t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), + _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), ...visibleCancelledShifts.map( (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), @@ -378,7 +378,7 @@ class _MyShiftsTabState extends State { // Confirmed Shifts if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader(t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), + _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), ...visibleMyShifts.map( (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), @@ -388,7 +388,7 @@ class _MyShiftsTabState extends State { onRequestSwap: () { UiSnackbar.show( context, - message: "Swap functionality coming soon!", // Todo: Localization + message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon, type: UiSnackbarType.message, ); }, @@ -402,8 +402,8 @@ class _MyShiftsTabState extends State { widget.cancelledShifts.isEmpty) EmptyStateView( icon: UiIcons.calendar, - title: t.staff_shifts.my_shifts_tab.empty.title, - subtitle: t.staff_shifts.my_shifts_tab.empty.subtitle, + title: context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: context.t.staff_shifts.my_shifts_tab.empty.subtitle, ), const SizedBox(height: UiConstants.space32), @@ -472,13 +472,13 @@ class _MyShiftsTabState extends State { ), const SizedBox(width: 6), Text( - t.staff_shifts.my_shifts_tab.card.cancelled, + context.t.staff_shifts.my_shifts_tab.card.cancelled, style: UiTypography.footnote2b.textError, ), if (isLastMinute) ...[ const SizedBox(width: 4), Text( - t.staff_shifts.my_shifts_tab.card.compensation, + context.t.staff_shifts.my_shifts_tab.card.compensation, style: UiTypography.footnote2m.textSuccess, ), ], diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index 37a06b67..a7da041c 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -356,7 +356,13 @@ query listShiftRolesByBusinessAndDateRange( locationAddress title status - order { id eventName orderType } + order { + id + eventName + orderType + hubManagerId + hubManager { id user { fullName } } + } } } } diff --git a/backend/dataconnect/functions/seed.gql b/backend/dataconnect/functions/seed.gql index c259d42a..cd89234f 100644 --- a/backend/dataconnect/functions/seed.gql +++ b/backend/dataconnect/functions/seed.gql @@ -19,6 +19,25 @@ mutation seedAll @transaction { userRole: "STAFF" } ) + user_3: user_insert( + data: { + id: "367IkYcvnyfMWVvBp2EV2YHKqDR2" + email: "testclient@gmail.com" + fullName: "Test Client" + role: USER + userRole: "BUSINESS" + } + ) + user_4: user_insert( + data: { + id: "testStaff12345678901234567890" + email: "teststaff@example.com" + phone: "+15555551234" + fullName: "Test Staff" + role: USER + userRole: "STAFF" + } + ) # Business business_1: business_insert( @@ -43,6 +62,28 @@ mutation seedAll @transaction { status: ACTIVE } ) + business_2: business_insert( + data: { + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + businessName: "Test Client Business" + userId: "367IkYcvnyfMWVvBp2EV2YHKqDR2" + contactName: "Test Client" + email: "testclient@gmail.com" + phone: "+1-555-000-0000" + address: "123 Main Street, Los Angeles, CA, USA" + city: "Los Angeles" + state: "CA" + street: "Main Street" + country: "US" + placeId: "ChIJN1t_tDeuEmsRUsoyG83frY4" + latitude: 34.052235 + longitude: -118.243683 + area: SOUTHERN_CALIFORNIA + sector: OTHER + rateGroup: PREMIUM + status: ACTIVE + } + ) # Team team_1: team_insert( @@ -55,6 +96,16 @@ mutation seedAll @transaction { totalHubs: 3 } ) + team_2: team_insert( + data: { + id: "b2c3d4e5-f6a7-8901-bcde-f12345678901" + teamName: "Test Client Team" + ownerId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ownerName: "Test Client" + ownerRole: "ADMIN" + totalHubs: 1 + } + ) # Team Hubs team_hub_1: teamHub_insert( @@ -105,6 +156,22 @@ mutation seedAll @transaction { isActive: true } ) + team_hub_4: teamHub_insert( + data: { + id: "d4e5f6a7-b8c9-0123-def0-234567890123" + teamId: "b2c3d4e5-f6a7-8901-bcde-f12345678901" + hubName: "Test Hub" + address: "123 Main Street, Los Angeles, CA, USA" + city: "Los Angeles" + state: "CA" + street: "Main Street" + country: "US" + placeId: "ChIJN1t_tDeuEmsRUsoyG83frY4" + latitude: 34.052235 + longitude: -118.243683 + isActive: true + } + ) # Vendor vendor_1: vendor_insert( @@ -326,6 +393,25 @@ mutation seedAll @transaction { englishRequired: true } ) + staff_7: staff_insert( + data: { + id: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + userId: "testStaff12345678901234567890" + fullName: "Test Staff" + email: "teststaff@example.com" + phone: "+1-555-555-1234" + ownerId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + addres: "123 Main Street, Los Angeles, CA 90001, USA" + city: "Los Angeles" + state: "CA" + street: "Main Street" + country: "US" + placeId: "ChIJN1t_tDeuEmsRUsoyG83frY4" + latitude: 34.052235 + longitude: -118.243683 + englishRequired: true + } + ) # Orders (20 total) order_01: order_insert(