diff --git a/apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart b/apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart index 2e72c233..4d4cdcdb 100644 --- a/apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart +++ b/apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart @@ -1,4 +1,5 @@ import 'dart:developer' as developer; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -118,3 +119,21 @@ mixin BlocErrorHandler { } } } + +/// Mixin to safely add events to a [Bloc] by checking if it is closed. +/// +/// This prevents the [StateError] "Cannot add new events after calling close". +mixin SafeBloc on Bloc { + @override + void add(E event) { + if (!isClosed) { + super.add(event); + } else { + developer.log( + 'SafeBloc: Attempted to add event $event to closed Bloc $runtimeType', + name: runtimeType.toString(), + level: 900, // Warning level + ); + } + } +} diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 5defb0ca..54e63c23 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -65,27 +65,27 @@ class StaffPaths { /// Home tab - the main dashboard for staff. /// /// Displays shift cards, quick actions, and notifications. - static const String home = '/worker-main/home'; + static const String home = '/worker-main/home/'; /// Shifts tab - view and manage shifts. /// /// Browse available shifts, accepted shifts, and shift history. - static const String shifts = '/worker-main/shifts'; + static const String shifts = '/worker-main/shifts/'; /// Payments tab - view payment history and earnings. /// /// Access payment history, earnings breakdown, and tax information. - static const String payments = '/worker-main/payments'; + static const String payments = '/worker-main/payments/'; /// Clock In tab - clock in/out functionality. /// /// Time tracking interface for active shifts. - static const String clockIn = '/worker-main/clock-in'; + static const String clockIn = '/worker-main/clock-in/'; /// Profile tab - staff member profile and settings. /// /// Manage personal information, documents, and preferences. - static const String profile = '/worker-main/profile'; + static const String profile = '/worker-main/profile/'; // ========================================================================== // SHIFT MANAGEMENT @@ -113,22 +113,22 @@ class StaffPaths { /// /// Collect basic personal information during staff onboarding. static const String onboardingPersonalInfo = - '/worker-main/onboarding/personal-info'; + '/worker-main/onboarding/personal-info/'; /// Emergency contact information. /// /// Manage emergency contact details for safety purposes. - static const String emergencyContact = '/worker-main/emergency-contact'; + static const String emergencyContact = '/worker-main/emergency-contact/'; /// Work experience information. /// /// Record previous work experience and qualifications. - static const String experience = '/worker-main/experience'; + static const String experience = '/worker-main/experience/'; /// Attire and appearance preferences. /// /// Record sizing and appearance information for uniform allocation. - static const String attire = '/worker-main/attire'; + static const String attire = '/worker-main/attire/'; // ========================================================================== // COMPLIANCE & DOCUMENTS @@ -137,12 +137,12 @@ class StaffPaths { /// Documents management - upload and manage required documents. /// /// Store ID, work permits, and other required documentation. - static const String documents = '/worker-main/documents'; + static const String documents = '/worker-main/documents/'; /// Certificates management - professional certifications. /// /// Manage professional certificates (e.g., food handling, CPR, etc.). - static const String certificates = '/worker-main/certificates'; + static const String certificates = '/worker-main/certificates/'; // ========================================================================== // FINANCIAL INFORMATION @@ -151,12 +151,12 @@ class StaffPaths { /// Bank account information for direct deposit. /// /// Manage banking details for payment processing. - static const String bankAccount = '/worker-main/bank-account'; + static const String bankAccount = '/worker-main/bank-account/'; /// Tax forms and withholding information. /// /// Manage W-4, tax withholding, and related tax documents. - static const String taxForms = '/worker-main/tax-forms'; + static const String taxForms = '/worker-main/tax-forms/'; /// Form I-9 - Employment Eligibility Verification. /// @@ -171,7 +171,7 @@ class StaffPaths { /// Time card - view detailed time tracking records. /// /// Access detailed time entries and timesheets. - static const String timeCard = '/worker-main/time-card'; + static const String timeCard = '/worker-main/time-card/'; // ========================================================================== // SCHEDULING & AVAILABILITY @@ -180,7 +180,7 @@ class StaffPaths { /// Availability management - set working hours preferences. /// /// Define when the staff member is available to work. - static const String availability = '/worker-main/availability'; + static const String availability = '/worker-main/availability/'; // ========================================================================== // ADDITIONAL FEATURES (Placeholders) diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 9688cdc9..80bacabe 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_bloc: ^8.1.0 design_system: path: ../design_system equatable: ^2.0.8 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 3326d7e9..851578db 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 @@ -593,6 +593,243 @@ "driving": "Driving" } } + }, + "clock_in": { + "title": "Clock In to your Shift", + "your_activity": "Your Activity", + "selected_shift_badge": "SELECTED SHIFT", + "today_shift_badge": "TODAY'S SHIFT", + "early_title": "You're early!", + "check_in_at": "Check-in available at $time", + "shift_completed": "Shift Completed!", + "great_work": "Great work today", + "no_shifts_today": "No confirmed shifts for today", + "accept_shift_cta": "Accept a shift to clock in", + "soon": "soon", + "checked_in_at_label": "Checked in at", + "nfc_dialog": { + "scan_title": "NFC Scan Required", + "scanned_title": "NFC Scanned", + "ready_to_scan": "Ready to Scan", + "scan_instruction": "Hold your phone near the NFC tag at the venue to check in.", + "tap_to_scan": "Tap to Scan (Simulated)", + "processing": "Checking Tag...", + "please_wait": "Hang tight, we're verifying your location." + }, + "commute": { + "enable_title": "Enable Commute Tracking?", + "enable_desc": "Share location 1hr before shift so your manager can see you're on the way.", + "not_now": "Not Now", + "enable": "Enable", + "on_my_way": "On My Way", + "starts_in": "Shift starts in $min min", + "track_arrival": "Track arrival", + "heading_to_site": "Your manager can see you're heading to the site", + "distance_to_site": "Distance to Site", + "estimated_arrival": "Estimated Arrival", + "eta_label": "$min min", + "locked_desc": "Most app features are locked while commute mode is on. You'll be able to clock in once you arrive.", + "turn_off": "Turn Off Commute Mode", + "arrived_title": "You've Arrived! 🎉", + "arrived_desc": "You're at the shift location. Ready to clock in?" + }, + "swipe": { + "checking_out": "Checking out...", + "checking_in": "Checking in...", + "nfc_checkout": "NFC Check Out", + "nfc_checkin": "NFC Check In", + "swipe_checkout": "Swipe to Check Out", + "swipe_checkin": "Swipe to Check In", + "checkout_complete": "Check Out!", + "checkin_complete": "Check In!" + }, + "lunch_break": { + "title": "Did You Take\na Lunch?", + "no": "No", + "yes": "Yes", + "when_title": "When did you take lunch?", + "start": "Start", + "end": "End", + "why_no_lunch": "Why didn't you take lunch?", + "reasons": [ + "Unpredictable Workflows", + "Poor Time Management", + "Lack of coverage or short Staff", + "No Lunch Area", + "Other (Please specify)" + ], + "additional_notes": "Additional Notes", + "notes_placeholder": "Add any details...", + "next": "Next", + "submit": "Submit", + "success_title": "Break Logged!", + "close": "Close" + } + }, + "availability": { + "title": "My Availability", + "quick_set_title": "Quick Set Availability", + "all_week": "All Week", + "weekdays": "Weekdays", + "weekends": "Weekends", + "clear_all": "Clear All", + "available_status": "You are available", + "not_available_status": "Not available", + "auto_match_title": "Auto-Match uses your availability", + "auto_match_description": "When enabled, you'll only be matched with shifts during your available times." + } + }, + "staff_compliance": { + "tax_forms": { + "w4": { + "title": "Form W-4", + "subtitle": "Employee's Withholding Certificate", + "submitted_title": "Form W-4 Submitted!", + "submitted_desc": "Your withholding certificate has been submitted to your employer.", + "back_to_docs": "Back to Documents", + "step_label": "Step $current of $total", + "steps": { + "personal": "Personal Information", + "filing": "Filing Status", + "multiple_jobs": "Multiple Jobs", + "dependents": "Dependents", + "adjustments": "Other Adjustments", + "review": "Review & Sign" + }, + "fields": { + "first_name": "First Name *", + "last_name": "Last Name *", + "ssn": "Social Security Number *", + "address": "Address *", + "city_state_zip": "City, State, ZIP", + "placeholder_john": "John", + "placeholder_smith": "Smith", + "placeholder_ssn": "XXX-XX-XXXX", + "placeholder_address": "123 Main Street", + "placeholder_csz": "San Francisco, CA 94102", + "filing_info": "Your filing status determines your standard deduction and tax rates.", + "single": "Single or Married filing separately", + "married": "Married filing jointly or Qualifying surviving spouse", + "head": "Head of household", + "head_desc": "Check only if you're unmarried and pay more than half the costs of keeping up a home", + "multiple_jobs_title": "When to complete this step?", + "multiple_jobs_desc": "Complete this step only if you hold more than one job at a time, or are married filing jointly and your spouse also works.", + "multiple_jobs_check": "I have multiple jobs or my spouse works", + "two_jobs_desc": "Check this box if there are only two jobs total", + "multiple_jobs_not_apply": "If this does not apply, you can continue to the next step", + "dependents_info": "If your total income will be $ 200,000 or less ($ 400,000 if married filing jointly), you may claim credits for dependents.", + "children_under_17": "Qualifying children under age 17", + "children_each": "$ 2,000 each", + "other_dependents": "Other dependents", + "other_each": "$ 500 each", + "total_credits": "Total credits (Step 3)", + "adjustments_info": "These adjustments are optional. You can skip them if they don't apply.", + "other_income": "4(a) Other income (not from jobs)", + "other_income_desc": "Include interest, dividends, retirement income", + "deductions": "4(b) Deductions", + "deductions_desc": "If you expect to claim deductions other than the standard deduction", + "extra_withholding": "4(c) Extra withholding", + "extra_withholding_desc": "Any additional tax you want withheld each pay period", + "summary_title": "Your W-4 Summary", + "summary_name": "Name", + "summary_ssn": "SSN", + "summary_filing": "Filing Status", + "summary_credits": "Credits", + "perjury_declaration": "Under penalties of perjury, I declare that this certificate, to the best of my knowledge and belief, is true, correct, and complete.", + "signature_label": "Signature (type your full name) *", + "signature_hint": "Type your full name", + "date_label": "Date", + "status_single": "Single", + "status_married": "Married", + "status_head": "Head of Household", + "back": "Back", + "continue": "Continue", + "submit": "Submit Form", + "step_counter": "Step {current} of {total}", + "hints": { + "first_name": "John", + "last_name": "Smith", + "ssn": "XXX-XX-XXXX", + "zero": "$ 0", + "email": "john.smith@example.com", + "phone": "(555) 555-5555" + } + } + }, + "i9": { + "title": "Form I-9", + "subtitle": "Employment Eligibility Verification", + "submitted_title": "Form I-9 Submitted!", + "submitted_desc": "Your employment eligibility verification has been submitted.", + "back": "Back", + "continue": "Continue", + "submit": "Submit Form", + "step_label": "Step $current of $total", + "steps": { + "personal": "Personal Information", + "personal_sub": "Name and contact details", + "address": "Address", + "address_sub": "Your current address", + "citizenship": "Citizenship Status", + "citizenship_sub": "Work authorization verification", + "review": "Review & Sign", + "review_sub": "Confirm your information" + }, + "fields": { + "first_name": "First Name *", + "last_name": "Last Name *", + "middle_initial": "Middle Initial", + "other_last_names": "Other Last Names", + "maiden_name": "Maiden name (if any)", + "dob": "Date of Birth *", + "ssn": "Social Security Number *", + "email": "Email Address", + "phone": "Phone Number", + "address_long": "Address (Street Number and Name) *", + "apt": "Apt. Number", + "city": "City or Town *", + "state": "State *", + "zip": "ZIP Code *", + "attestation": "I attest, under penalty of perjury, that I am (check one of the following boxes):", + "citizen": "1. A citizen of the United States", + "noncitizen": "2. A noncitizen national of the United States", + "permanent_resident": "3. A lawful permanent resident", + "uscis_number_label": "USCIS Number", + "alien": "4. An alien authorized to work", + "admission_number": "USCIS/Admission Number", + "passport": "Foreign Passport Number", + "country": "Country of Issuance", + "summary_title": "Summary", + "summary_name": "Name", + "summary_address": "Address", + "summary_ssn": "SSN", + "summary_citizenship": "Citizenship", + "status_us_citizen": "US Citizen", + "status_noncitizen": "Noncitizen National", + "status_permanent_resident": "Permanent Resident", + "status_alien": "Alien Authorized to Work", + "status_unknown": "Unknown", + "preparer": "I used a preparer or translator", + "warning": "I am aware that federal law provides for imprisonment and/or fines for false statements or use of false documents in connection with the completion of this form.", + "signature_label": "Signature (type your full name) *", + "signature_hint": "Type your full name", + "date_label": "Date", + "hints": { + "first_name": "John", + "last_name": "Smith", + "middle_initial": "A", + "dob": "MM/DD/YYYY", + "ssn": "XXX-XX-XXXX", + "email": "john.smith@example.com", + "phone": "(555) 555-5555", + "address": "123 Main Street", + "apt": "4B", + "city": "San Francisco", + "zip": "94103", + "uscis": "A-123456789" + } + } + } } }, "staff_documents": { @@ -717,6 +954,40 @@ "tags": { "immediate_start": "Immediate start", "no_experience": "No experience" + }, + "shift_details": { + "vendor": "VENDOR", + "shift_date": "SHIFT DATE", + "slots_remaining": "$count slots remaining", + "start_time": "START TIME", + "end_time": "END TIME", + "base_rate": "Base Rate", + "duration": "Duration", + "est_total": "Est. Total", + "hours_label": "$count hours", + "location": "LOCATION", + "open_in_maps": "Open in Maps", + "job_description": "JOB DESCRIPTION", + "cancel_shift": "CANCEL SHIFT", + "clock_in": "CLOCK IN", + "decline": "DECLINE", + "accept_shift": "ACCEPT SHIFT", + "apply_now": "APPLY NOW", + "book_dialog": { + "title": "Book Shift", + "message": "Do you want to instantly book this shift?" + }, + "decline_dialog": { + "title": "Decline Shift", + "message": "Are you sure you want to decline this shift? It will be hidden from your available jobs." + }, + "cancel_dialog": { + "title": "Cancel Shift", + "message": "Are you sure you want to cancel this shift?" + }, + "applying_dialog": { + "title": "Applying" + } } }, "staff_time_card": { @@ -745,6 +1016,7 @@ "sign_up_failed": "We couldn't create your account. Please try again.", "sign_in_failed": "We couldn't sign you in. Please try again.", "not_authenticated": "Please sign in to continue.", + "passwords_dont_match": "Passwords do not match", "password_mismatch": "This email is already registered. Please use the correct password or tap 'Forgot Password' to reset it.", "google_only_account": "This email is registered via Google. Please use 'Forgot Password' to set a password, then try signing up again with the same information." }, @@ -788,6 +1060,9 @@ }, "profile": { "updated": "Profile updated successfully!" + }, + "availability": { + "updated": "Availability updated successfully" } } } \ No newline at end of file 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 bf7e54a6..6abaaf82 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 @@ -557,40 +557,277 @@ "save_success": "Información personal guardada exitosamente" }, "experience": { - "title": "Experience & Skills", - "industries_title": "Industries", - "industries_subtitle": "Select the industries you have experience in", - "skills_title": "Skills", - "skills_subtitle": "Select your skills or add custom ones", - "custom_skills_title": "Custom Skills:", - "custom_skill_hint": "Add custom skill...", - "save_button": "Save & Continue", + "title": "Experiencia y habilidades", + "industries_title": "Industrias", + "industries_subtitle": "Seleccione las industrias en las que tiene experiencia", + "skills_title": "Habilidades", + "skills_subtitle": "Seleccione sus habilidades o añada personalizadas", + "custom_skills_title": "Habilidades personalizadas:", + "custom_skill_hint": "Añadir habilidad...", + "save_button": "Guardar y continuar", "industries": { - "hospitality": "Hospitality", - "food_service": "Food Service", - "warehouse": "Warehouse", - "events": "Events", - "retail": "Retail", - "healthcare": "Healthcare", - "other": "Other" + "hospitality": "Hotelería", + "food_service": "Servicio de alimentos", + "warehouse": "Almacén", + "events": "Eventos", + "retail": "Venta al por menor", + "healthcare": "Cuidado de la salud", + "other": "Otro" }, "skills": { - "food_service": "Food Service", + "food_service": "Servicio de alimentos", "bartending": "Bartending", - "event_setup": "Event Setup", - "hospitality": "Hospitality", - "warehouse": "Warehouse", - "customer_service": "Customer Service", - "cleaning": "Cleaning", - "security": "Security", - "retail": "Retail", - "cooking": "Cooking", - "cashier": "Cashier", - "server": "Server", + "event_setup": "Montaje de eventos", + "hospitality": "Hotelería", + "warehouse": "Almacén", + "customer_service": "Servicio al cliente", + "cleaning": "Limpieza", + "security": "Seguridad", + "retail": "Venta al por menor", + "cooking": "Cocinar", + "cashier": "Cajero", + "server": "Mesero", "barista": "Barista", - "host_hostess": "Host/Hostess", - "busser": "Busser", - "driving": "Driving" + "host_hostess": "Anfitrión/Anfitriona", + "busser": "Ayudante de mesero", + "driving": "Conducir" + } + } + }, + "clock_in": { + "title": "Registrar entrada en su turno", + "your_activity": "Su actividad", + "selected_shift_badge": "TURNO SELECCIONADO", + "today_shift_badge": "TURNO DE HOY", + "early_title": "¡Ha llegado temprano!", + "check_in_at": "Entrada disponible a las $time", + "shift_completed": "¡Turno completado!", + "great_work": "Buen trabajo hoy", + "no_shifts_today": "No hay turnos confirmados para hoy", + "accept_shift_cta": "Acepte un turno para registrar su entrada", + "soon": "pronto", + "checked_in_at_label": "Entrada registrada a las", + "nfc_dialog": { + "scan_title": "Escaneo NFC requerido", + "scanned_title": "NFC escaneado", + "ready_to_scan": "Listo para escanear", + "processing": "Verificando etiqueta...", + "scan_instruction": "Mantenga su teléfono cerca de la etiqueta NFC en el lugar para registrarse.", + "please_wait": "Espere un momento, estamos verificando su ubicación.", + "tap_to_scan": "Tocar para escanear (Simulado)" + }, + "commute": { + "enable_title": "¿Activar seguimiento de viaje?", + "enable_desc": "Comparta su ubicación 1 hora antes del turno para que su gerente sepa que está en camino.", + "not_now": "Ahora no", + "enable": "Activar", + "on_my_way": "En camino", + "starts_in": "El turno comienza en $min min", + "track_arrival": "Seguimiento de llegada", + "heading_to_site": "Su gerente puede ver que se dirige al sitio", + "distance_to_site": "Distancia al sitio", + "estimated_arrival": "Llegada estimada", + "eta_label": "$min min", + "locked_desc": "La mayoría de las funciones de la aplicación están bloqueadas mientras el modo de viaje está activo. Podrá registrar su entrada una vez que llegue.", + "turn_off": "Desactivar modo de viaje", + "arrived_title": "¡Has llegado! 🎉", + "arrived_desc": "Estás en el lugar del turno. ¿Listo para registrar tu entrada?" + }, + "swipe": { + "checking_out": "Registrando salida...", + "checking_in": "Registrando entrada...", + "nfc_checkout": "NFC Salida", + "nfc_checkin": "NFC Entrada", + "swipe_checkout": "Deslizar para registrar salida", + "swipe_checkin": "Deslizar para registrar entrada", + "checkout_complete": "¡Salida registrada!", + "checkin_complete": "¡Entrada registrada!" + }, + "lunch_break": { + "title": "¿Tomaste un\nalmuerzo?", + "no": "No", + "yes": "Sí", + "when_title": "¿Cuándo almorzaste?", + "start": "Inicio", + "end": "Fin", + "why_no_lunch": "¿Por qué no almorzaste?", + "reasons": [ + "Flujos de trabajo impredecibles", + "Mala gestión del tiempo", + "Falta de cobertura o poco personal", + "No hay área de almuerzo", + "Otro (especifique)" + ], + "additional_notes": "Notas adicionales", + "notes_placeholder": "Añade cualquier detalle...", + "next": "Siguiente", + "submit": "Enviar", + "success_title": "¡Descanso registrado!", + "close": "Cerrar" + } + }, + "availability": { + "title": "Mi disponibilidad", + "quick_set_title": "Establecer disponibilidad rápida", + "all_week": "Toda la semana", + "weekdays": "Días laborables", + "weekends": "Fines de semana", + "clear_all": "Borrar todo", + "available_status": "Está disponible", + "not_available_status": "No disponible", + "auto_match_title": "Auto-Match usa su disponibilidad", + "auto_match_description": "Cuando esté activado, solo se le asignarán turnos durante sus horarios disponibles." + } + }, + "staff_compliance": { + "tax_forms": { + "w4": { + "title": "Formulario W-4", + "subtitle": "Certificado de Retención del Empleado", + "submitted_title": "¡Formulario W-4 enviado!", + "submitted_desc": "Su certificado de retención ha sido enviado a su empleador.", + "back_to_docs": "Volver a Documentos", + "step_label": "Paso $current de $total", + "steps": { + "personal": "Información Personal", + "filing": "Estado Civil para Efectos de la Declaración", + "multiple_jobs": "Múltiples Trabajos", + "dependents": "Dependientes", + "adjustments": "Otros Ajustes", + "review": "Revisar y Firmar" + }, + "fields": { + "first_name": "Nombre *", + "last_name": "Apellido *", + "ssn": "Número de Seguro Social *", + "address": "Dirección *", + "city_state_zip": "Ciudad, Estado, Código Postal", + "placeholder_john": "Juan", + "placeholder_smith": "Pérez", + "placeholder_ssn": "XXX-XX-XXXX", + "placeholder_address": "Calle Principal 123", + "placeholder_csz": "Ciudad de México, CDMX 01000", + "filing_info": "Su estado civil determina su deducción estándar y tasas de impuestos.", + "single": "Soltero o Casado que presenta la declaración por separado", + "married": "Casado que presenta una declaración conjunta o Cónyuge sobreviviente calificado", + "head": "Jefe de familia", + "head_desc": "Marque solo si es soltero y paga más de la mitad de los costos de mantenimiento de un hogar", + "multiple_jobs_title": "¿Cuándo completar este paso?", + "multiple_jobs_desc": "Complete este paso solo si tiene más de un trabajo a la vez, o si está casado y presenta una declaración conjunta y su cónyuge también trabaja.", + "multiple_jobs_check": "Tengo múltiples trabajos o mi cónyuge trabaja", + "two_jobs_desc": "Marque esta casilla si solo hay dos trabajos en total", + "multiple_jobs_not_apply": "Si esto no se aplica, puede continuar al siguiente paso", + "dependents_info": "Si su ingreso total será de $ 200,000 o menos ($ 400,000 si está casado y presenta una declaración conjunta), puede reclamar créditos por dependientes.", + "children_under_17": "Hijos calificados menores de 17 años", + "children_each": "$ 2,000 cada uno", + "other_dependents": "Otros dependientes", + "other_each": "$ 500 cada uno", + "total_credits": "Créditos totales (Paso 3)", + "adjustments_info": "Estos ajustes son opcionales. Puede omitirlos si no se aplican.", + "other_income": "4(a) Otros ingresos (no provenientes de trabajos)", + "other_income_desc": "Incluya intereses, dividendos, ingresos de jubilación", + "deductions": "4(b) Deducciones", + "deductions_desc": "Si espera reclamar deducciones distintas de la deducción estándar", + "extra_withholding": "4(c) Retención adicional", + "extra_withholding_desc": "Cualquier impuesto adicional que desee que se le retenga en cada período de pago", + "summary_title": "Su Resumen de W-4", + "summary_name": "Nombre", + "summary_ssn": "SSN", + "summary_filing": "Estado Civil", + "summary_credits": "Créditos", + "perjury_declaration": "Bajo pena de perjurio, declaro que este certificado, según mi leal saber y entender, es verdadero, correcto y completo.", + "signature_label": "Firma (escriba su nombre completo) *", + "signature_hint": "Escriba su nombre completo", + "date_label": "Fecha", + "status_single": "Soltero/a", + "status_married": "Casado/a", + "status_head": "Cabeza de familia", + "back": "Atrás", + "continue": "Continuar", + "submit": "Enviar Formulario", + "step_counter": "Paso {current} de {total}", + "hints": { + "first_name": "Juan", + "last_name": "Pérez", + "ssn": "XXX-XX-XXXX", + "zero": "$ 0", + "email": "juan.perez@ejemplo.com", + "phone": "(555) 555-5555" + } + } + }, + "i9": { + "title": "Formulario I-9", + "subtitle": "Verificación de Elegibilidad de Empleo", + "submitted_title": "¡Formulario I-9 enviado!", + "submitted_desc": "Su verificación de elegibilidad de empleo ha sido enviada.", + "back": "Atrás", + "continue": "Continuar", + "submit": "Enviar Formulario", + "step_label": "Paso $current de $total", + "steps": { + "personal": "Información Personal", + "personal_sub": "Nombre y detalles de contacto", + "address": "Dirección", + "address_sub": "Su dirección actual", + "citizenship": "Estado de Ciudadanía", + "citizenship_sub": "Verificación de autorización de trabajo", + "review": "Revisar y Firmar", + "review_sub": "Confirme su información" + }, + "fields": { + "first_name": "Nombre *", + "last_name": "Apellido *", + "middle_initial": "Inicial del segundo nombre", + "other_last_names": "Otros apellidos", + "maiden_name": "Apellido de soltera (si hay)", + "dob": "Fecha de Nacimiento *", + "ssn": "Número de Seguro Social *", + "email": "Correo electrónico", + "phone": "Número de teléfono", + "address_long": "Dirección (Número y nombre de la calle) *", + "apt": "Núm. de apartamento", + "city": "Ciudad o Pueblo *", + "state": "Estado *", + "zip": "Código Postal *", + "attestation": "Doy fe, bajo pena de perjurio, de que soy (marque una de las siguientes casillas):", + "citizen": "1. Ciudadano de los Estados Unidos", + "noncitizen": "2. Nacional no ciudadano de los Estados Unidos", + "permanent_resident": "3. Residente permanente legal", + "uscis_number_label": "Número USCIS", + "alien": "4. Un extranjero autorizado para trabajar", + "admission_number": "Número USCIS/Admisión", + "passport": "Número de pasaporte extranjero", + "country": "País de emisión", + "summary_title": "Resumen", + "summary_name": "Nombre", + "summary_address": "Dirección", + "summary_ssn": "SSN", + "summary_citizenship": "Ciudadanía", + "status_us_citizen": "Ciudadano de los EE. UU.", + "status_noncitizen": "Nacional no ciudadano", + "status_permanent_resident": "Residente permanente", + "status_alien": "Extranjero autorizado para trabajar", + "status_unknown": "Desconocido", + "preparer": "Utilicé un preparador o traductor", + "warning": "Soy consciente de que la ley federal prevé penas de prisión y/o multas por declaraciones falsas o uso de documentos falsos en relación con la cumplimentación de este formulario.", + "signature_label": "Firma (escriba su nombre completo) *", + "signature_hint": "Escriba su nombre completo", + "date_label": "Fecha", + "hints": { + "first_name": "Juan", + "last_name": "Pérez", + "middle_initial": "J", + "dob": "MM/DD/YYYY", + "ssn": "XXX-XX-XXXX", + "email": "juan.perez@ejemplo.com", + "phone": "(555) 555-5555", + "address": "Calle Principal 123", + "apt": "4B", + "city": "San Francisco", + "zip": "94103", + "uscis": "A-123456789" + } } } } @@ -717,6 +954,40 @@ "tags": { "immediate_start": "Inicio inmediato", "no_experience": "Sin experiencia" + }, + "shift_details": { + "vendor": "PROVEEDOR", + "shift_date": "FECHA DEL TURNO", + "slots_remaining": "$count puestos restantes", + "start_time": "HORA DE INICIO", + "end_time": "HORA DE FIN", + "base_rate": "Tarifa base", + "duration": "Duración", + "est_total": "Total est.", + "hours_label": "$count horas", + "location": "UBICACIÓN", + "open_in_maps": "Abrir en Mapas", + "job_description": "DESCRIPCIÓN DEL TRABAJO", + "cancel_shift": "CANCELAR TURNO", + "clock_in": "ENTRADA", + "decline": "RECHAZAR", + "accept_shift": "ACEPTAR TURNO", + "apply_now": "SOLICITAR AHORA", + "book_dialog": { + "title": "Reservar turno", + "message": "¿Desea reservar este turno al instante?" + }, + "decline_dialog": { + "title": "Rechazar turno", + "message": "¿Está seguro de que desea rechazar este turno? Se ocultará de sus trabajos disponibles." + }, + "cancel_dialog": { + "title": "Cancelar turno", + "message": "¿Está seguro de que desea cancelar este turno?" + }, + "applying_dialog": { + "title": "Solicitando" + } } }, "staff_time_card": { @@ -745,6 +1016,7 @@ "sign_up_failed": "No pudimos crear tu cuenta. Por favor, intenta de nuevo.", "sign_in_failed": "No pudimos iniciar sesión. Por favor, intenta de nuevo.", "not_authenticated": "Por favor, inicia sesión para continuar.", + "passwords_dont_match": "Las contraseñas no coinciden", "password_mismatch": "Este correo ya está registrado. Por favor, usa la contraseña correcta o toca 'Olvidé mi contraseña' para restablecerla.", "google_only_account": "Este correo está registrado con Google. Por favor, usa 'Olvidé mi contraseña' para establecer una contraseña, luego intenta registrarte de nuevo con la misma información." }, @@ -787,7 +1059,10 @@ "created": "¡Orden creada exitosamente!" }, "profile": { - "updated": "¡Perfil actualizado exitosamente!" + "updated": "¡Perfil actualizado con éxito!" + }, + "availability": { + "updated": "Disponibilidad actualizada con éxito" } } } \ No newline at end of file diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_snackbar.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_snackbar.dart index 25ca55d0..6c4bc719 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_snackbar.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_snackbar.dart @@ -29,6 +29,7 @@ class UiSnackbar { required String message, required UiSnackbarType type, Duration duration = const Duration(seconds: 3), + EdgeInsetsGeometry? margin, }) { final Color textColor; final Color backgroundColor; @@ -63,6 +64,7 @@ class UiSnackbar { backgroundColor: UiColors.transparent, elevation: 0, behavior: SnackBarBehavior.floating, + margin: margin ?? const EdgeInsets.all(16), content: ClipRRect( borderRadius: BorderRadius.circular(12), child: BackdropFilter( 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 1841794c..6b07dcf1 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 @@ -12,7 +12,9 @@ import 'package:krow_domain/krow_domain.dart' AccountExistsException, UserNotFoundException, UnauthorizedAppException, - PasswordMismatchException; + UnauthorizedAppException, + PasswordMismatchException, + NetworkException; import 'package:krow_domain/krow_domain.dart' as domain; import '../../domain/repositories/auth_repository_interface.dart'; @@ -63,6 +65,10 @@ class AuthRepositoryImpl throw InvalidCredentialsException( technicalMessage: 'Firebase error code: ${e.code}', ); + } else if (e.code == 'network-request-failed') { + throw NetworkException( + technicalMessage: 'Firebase: ${e.message}', + ); } else { throw SignInFailedException( technicalMessage: 'Firebase auth error: ${e.message}', @@ -120,6 +126,10 @@ class AuthRepositoryImpl password: password, companyName: companyName, ); + } else if (e.code == 'network-request-failed') { + throw NetworkException( + technicalMessage: 'Firebase: ${e.message}', + ); } else { throw SignUpFailedException( technicalMessage: 'Firebase auth error: ${e.message}', diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_in_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_in_page.dart index 64195172..59622220 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_in_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_in_page.dart @@ -47,8 +47,11 @@ class ClientSignInPage extends StatelessWidget { final String errorMessage = state.errorMessage != null ? translateErrorKey(state.errorMessage!) : t.errors.generic.unknown; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage)), + UiSnackbar.show( + context, + message: errorMessage, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16), ); } }, diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart index e8453f17..c6b8425f 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart @@ -51,8 +51,11 @@ class ClientSignUpPage extends StatelessWidget { final String errorMessage = state.errorMessage != null ? translateErrorKey(state.errorMessage!) : t.errors.generic.unknown; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage)), + UiSnackbar.show( + context, + message: errorMessage, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16), ); } }, diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart index da3c215c..b6617bdc 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart @@ -47,9 +47,11 @@ class _ClientSignUpFormState extends State { void _handleSubmit() { if (_passwordController.text != _confirmPasswordController.text) { - ScaffoldMessenger.of( + UiSnackbar.show( context, - ).showSnackBar(const SnackBar(content: Text('Passwords do not match'))); + message: translateErrorKey('passwords_dont_match'), + type: UiSnackbarType.error, + ); return; } 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 500cb16f..8d29406f 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 @@ -71,13 +71,26 @@ class _BillingViewState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listener: (BuildContext context, BillingState state) { + if (state.status == BillingStatus.failure && state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, builder: (BuildContext context, BillingState state) { return Scaffold( body: CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( + // ... (APP BAR CODE REMAINS UNCHANGED, BUT I MUST INCLUDE IT OR CHUNK IT CORRECTLY) + // Since I cannot see the headers in this chunk, I will target the _buildContent method instead + // to avoid messing up the whole file structure. + // Wait, I can just replace the build method wrapper. pinned: true, expandedHeight: 200.0, backgroundColor: UiColors.primary, @@ -180,17 +193,28 @@ class _BillingViewState extends State { } if (state.status == BillingStatus.failure) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(translateErrorKey(state.errorMessage!))), - ); - }); - return Center( - child: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - style: UiTypography.body1r.textError, + return Padding( + padding: const EdgeInsets.all(UiConstants.space8), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(UiIcons.error, size: 48, color: UiColors.error), + const SizedBox(height: UiConstants.space4), + Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space4), + UiButton.secondary( + text: 'Retry', + onPressed: () => BlocProvider.of(context).add(const BillingLoadStarted()), + ), + ], + ), ), ); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index e7cb2bd9..cd02bef6 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -23,43 +23,56 @@ class CoverageRepositoryImpl implements CoverageRepository { /// Fetches shifts for a specific date. @override Future> getShiftsForDate({required DateTime date}) async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; + try { + final String? businessId = + dc.ClientSessionStore.instance.session?.business?.id; + if (businessId == null || businessId.isEmpty) { + return []; + } + + final DateTime start = DateTime(date.year, date.month, date.day); + final DateTime end = + DateTime(date.year, date.month, date.day, 23, 59, 59, 999); + + final fdc.QueryResult< + dc.ListShiftRolesByBusinessAndDateRangeData, + dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = + await _dataConnect + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _toTimestamp(start), + end: _toTimestamp(end), + ) + .execute(); + + final fdc.QueryResult< + dc.ListStaffsApplicationsByBusinessForDayData, + dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = + await _dataConnect + .listStaffsApplicationsByBusinessForDay( + businessId: businessId, + dayStart: _toTimestamp(start), + dayEnd: _toTimestamp(end), + ) + .execute(); + + return _mapCoverageShifts( + shiftRolesResult.data.shiftRoles, + applicationsResult.data.applications, + date, + ); + } catch (e) { + final String error = e.toString().toLowerCase(); + if (error.contains('network') || + error.contains('connection') || + error.contains('unavailable') || + error.contains('offline') || + error.contains('socket') || + error.contains('failed host lookup')) { + throw NetworkException(technicalMessage: 'Coverage fetch failed: $e'); + } + throw ServerException(technicalMessage: 'Coverage fetch failed: $e'); } - - final DateTime start = DateTime(date.year, date.month, date.day); - final DateTime end = - DateTime(date.year, date.month, date.day, 23, 59, 59, 999); - - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = - await _dataConnect - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), - ) - .execute(); - - final fdc.QueryResult< - dc.ListStaffsApplicationsByBusinessForDayData, - dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = - await _dataConnect - .listStaffsApplicationsByBusinessForDay( - businessId: businessId, - dayStart: _toTimestamp(start), - dayEnd: _toTimestamp(end), - ) - .execute(); - - return _mapCoverageShifts( - shiftRolesResult.data.shiftRoles, - applicationsResult.data.applications, - date, - ); } /// Fetches coverage statistics for a specific date. @@ -180,6 +193,7 @@ class CoverageRepositoryImpl implements CoverageRepository { case dc.ApplicationStatus.REJECTED: return CoverageWorkerStatus.rejected; case dc.ApplicationStatus.CONFIRMED: + case dc.ApplicationStatus.ACCEPTED: return CoverageWorkerStatus.confirmed; case dc.ApplicationStatus.CHECKED_IN: return CoverageWorkerStatus.checkedIn; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 9e081c29..697fc13d 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; +import 'package:core_localization/core_localization.dart'; import '../blocs/coverage_bloc.dart'; import '../blocs/coverage_event.dart'; import '../blocs/coverage_state.dart'; @@ -57,7 +58,16 @@ class _CoveragePageState extends State { create: (BuildContext context) => Modular.get() ..add(CoverageLoadRequested(date: DateTime.now())), child: Scaffold( - body: BlocBuilder( + body: BlocConsumer( + listener: (BuildContext context, CoverageState state) { + if (state.status == CoverageStatus.failure && state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, builder: (BuildContext context, CoverageState state) { final DateTime selectedDate = state.selectedDate ?? DateTime.now(); @@ -226,43 +236,45 @@ class _CoveragePageState extends State { required BuildContext context, required CoverageState state, }) { - if (state.status == CoverageStatus.loading) { - return const Center( - child: CircularProgressIndicator(), - ); - } + if (state.shifts.isEmpty) { + if (state.status == CoverageStatus.loading) { + return const Center( + child: CircularProgressIndicator(), + ); + } - if (state.status == CoverageStatus.failure) { - return Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.warning, - size: UiConstants.space12, - color: UiColors.destructive, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'Failed to load coverage data', - style: UiTypography.title2m.copyWith( - color: UiColors.textPrimary, + if (state.status == CoverageStatus.failure) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.error, + size: 48, + color: UiColors.error, ), - ), - const SizedBox(height: UiConstants.space2), - Text( - state.errorMessage ?? 'An unknown error occurred', - style: UiTypography.body2r.copyWith( - color: UiColors.mutedForeground, + const SizedBox(height: UiConstants.space4), + Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - ], + const SizedBox(height: UiConstants.space4), + UiButton.secondary( + text: 'Retry', + onPressed: () => BlocProvider.of(context).add( + const CoverageRefreshRequested(), + ), + ), + ], + ), ), - ), - ); + ); + } } return Padding( diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart index aa6abf8b..30fe20e1 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart @@ -10,7 +10,7 @@ import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. class OneTimeOrderBloc extends Bloc - with BlocErrorHandler { + with BlocErrorHandler, SafeBloc { OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart index 872723bc..d21bbfc3 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart @@ -71,6 +71,20 @@ class OneTimeOrderState extends Equatable { ); } + bool get isValid { + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + positions.every( + (OneTimeOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + @override List get props => [ date, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index cda38edf..3524786b 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -25,7 +25,18 @@ class OneTimeOrderView extends StatelessWidget { final TranslationsClientCreateOrderOneTimeEn labels = t.client_create_order.one_time; - return BlocBuilder( + return BlocConsumer( + listener: (BuildContext context, OneTimeOrderState state) { + if (state.status == OneTimeOrderStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + } + }, builder: (BuildContext context, OneTimeOrderState state) { if (state.status == OneTimeOrderStatus.success) { return OneTimeOrderSuccessView( @@ -104,9 +115,11 @@ class OneTimeOrderView extends StatelessWidget { ? labels.creating : labels.create_order, isLoading: state.status == OneTimeOrderStatus.loading, - onPressed: () => BlocProvider.of( - context, - ).add(const OneTimeOrderSubmitted()), + onPressed: state.isValid + ? () => BlocProvider.of( + context, + ).add(const OneTimeOrderSubmitted()) + : null, ), ], ), @@ -286,7 +299,7 @@ class _BottomActionButton extends StatelessWidget { this.isLoading = false, }); final String label; - final VoidCallback onPressed; + final VoidCallback? onPressed; final bool isLoading; @override 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 fb63d85b..547f9c65 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 @@ -15,92 +15,103 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future getDashboardData() async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return const HomeDashboardData( - weeklySpending: 0, - next7DaysSpending: 0, - weeklyShifts: 0, - next7DaysScheduled: 0, - totalNeeded: 0, - totalFilled: 0, + try { + final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; + if (businessId == null || businessId.isEmpty) { + return const HomeDashboardData( + weeklySpending: 0, + next7DaysSpending: 0, + weeklyShifts: 0, + next7DaysScheduled: 0, + totalNeeded: 0, + totalFilled: 0, + ); + } + + final DateTime now = DateTime.now(); + final int daysFromMonday = now.weekday - DateTime.monday; + final DateTime monday = + DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); + final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); + final DateTime weekRangeEnd = + DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); + final fdc.QueryResult< + dc.GetCompletedShiftsByBusinessIdData, + dc.GetCompletedShiftsByBusinessIdVariables> completedResult = + await _dataConnect + .getCompletedShiftsByBusinessId( + businessId: businessId, + dateFrom: _toTimestamp(weekRangeStart), + dateTo: _toTimestamp(weekRangeEnd), + ) + .execute(); + + double weeklySpending = 0.0; + double next7DaysSpending = 0.0; + int weeklyShifts = 0; + int next7DaysScheduled = 0; + for (final dc.GetCompletedShiftsByBusinessIdShifts shift + in completedResult.data.shifts) { + final DateTime? shiftDate = shift.date?.toDateTime(); + if (shiftDate == null) { + continue; + } + final int offset = shiftDate.difference(weekRangeStart).inDays; + if (offset < 0 || offset > 13) { + continue; + } + final double cost = shift.cost ?? 0.0; + if (offset <= 6) { + weeklySpending += cost; + weeklyShifts += 1; + } else { + next7DaysSpending += cost; + next7DaysScheduled += 1; + } + } + + final DateTime start = DateTime(now.year, now.month, now.day); + final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); + + final fdc.QueryResult< + dc.ListShiftRolesByBusinessAndDateRangeData, + dc.ListShiftRolesByBusinessAndDateRangeVariables> result = + await _dataConnect + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _toTimestamp(start), + end: _toTimestamp(end), + ) + .execute(); + + int totalNeeded = 0; + int totalFilled = 0; + for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole + in result.data.shiftRoles) { + totalNeeded += shiftRole.count; + totalFilled += shiftRole.assigned ?? 0; + } + + return HomeDashboardData( + weeklySpending: weeklySpending, + next7DaysSpending: next7DaysSpending, + weeklyShifts: weeklyShifts, + next7DaysScheduled: next7DaysScheduled, + totalNeeded: totalNeeded, + totalFilled: totalFilled, ); - } - - final DateTime now = DateTime.now(); - final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = - DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); - final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); - final DateTime weekRangeEnd = - DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); - final fdc.QueryResult< - dc.GetCompletedShiftsByBusinessIdData, - dc.GetCompletedShiftsByBusinessIdVariables> completedResult = - await _dataConnect - .getCompletedShiftsByBusinessId( - businessId: businessId, - dateFrom: _toTimestamp(weekRangeStart), - dateTo: _toTimestamp(weekRangeEnd), - ) - .execute(); - - - double weeklySpending = 0.0; - double next7DaysSpending = 0.0; - int weeklyShifts = 0; - int next7DaysScheduled = 0; - for (final dc.GetCompletedShiftsByBusinessIdShifts shift - in completedResult.data.shifts) { - final DateTime? shiftDate = shift.date?.toDateTime(); - if (shiftDate == null) { - continue; - } - final int offset = shiftDate.difference(weekRangeStart).inDays; - if (offset < 0 || offset > 13) { - continue; - } - final double cost = shift.cost ?? 0.0; - if (offset <= 6) { - weeklySpending += cost; - weeklyShifts += 1; - } else { - next7DaysSpending += cost; - next7DaysScheduled += 1; + } catch (e) { + final String error = e.toString().toLowerCase(); + if (error.contains('network') || + error.contains('connection') || + error.contains('unavailable') || + error.contains('offline') || + error.contains('socket') || + error.contains('failed host lookup')) { + throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e'); } + throw ServerException(technicalMessage: 'Home dashboard fetch failed: $e'); } - - final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); - - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables> result = - await _dataConnect - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), - ) - .execute(); - - - int totalNeeded = 0; - int totalFilled = 0; - for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole - in result.data.shiftRoles) { - totalNeeded += shiftRole.count; - totalFilled += shiftRole.assigned ?? 0; - } - - return HomeDashboardData( - weeklySpending: weeklySpending, - next7DaysSpending: next7DaysSpending, - weeklyShifts: weeklyShifts, - next7DaysScheduled: next7DaysScheduled, - totalNeeded: totalNeeded, - totalFilled: totalFilled, - ); } @override @@ -114,46 +125,57 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future> getRecentReorders() async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return const []; + try { + final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; + if (businessId == null || businessId.isEmpty) { + return const []; + } + + final DateTime now = DateTime.now(); + final DateTime start = now.subtract(const Duration(days: 30)); + final fdc.Timestamp startTimestamp = _toTimestamp(start); + final fdc.Timestamp endTimestamp = _toTimestamp(now); + + final fdc.QueryResult< + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result = + await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ).execute(); + + return result.data.shiftRoles.map(( + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, + ) { + + final String location = + shiftRole.shift.location ?? + shiftRole.shift.locationAddress ?? + ''; + final String type = shiftRole.shift.order.orderType.stringValue; + return ReorderItem( + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + location: location, + hourlyRate: shiftRole.role.costPerHour, + hours: shiftRole.hours ?? 0, + workers: shiftRole.count, + type: type, + ); + }).toList(); + } catch (e) { + final String error = e.toString().toLowerCase(); + if (error.contains('network') || + error.contains('connection') || + error.contains('unavailable') || + error.contains('offline') || + error.contains('socket') || + error.contains('failed host lookup')) { + throw NetworkException(technicalMessage: 'Home reorders fetch failed: $e'); + } + throw ServerException(technicalMessage: 'Home reorders fetch failed: $e'); } - - final DateTime now = DateTime.now(); - final DateTime start = now.subtract(const Duration(days: 30)); - final fdc.Timestamp startTimestamp = _toTimestamp(start); - final fdc.Timestamp endTimestamp = _toTimestamp(now); - - final fdc.QueryResult< - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result = - await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ).execute(); - - - - return result.data.shiftRoles.map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, - ) { - - final String location = - shiftRole.shift.location ?? - shiftRole.shift.locationAddress ?? - ''; - final String type = shiftRole.shift.order.orderType.stringValue; - return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', - location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, - ); - }).toList(); } fdc.Timestamp _toTimestamp(DateTime date) { diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index c1e3fefc..a2cc7629 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -10,7 +10,7 @@ import 'client_home_state.dart'; /// BLoC responsible for managing the state and business logic of the client home dashboard. class ClientHomeBloc extends Bloc - with BlocErrorHandler { + with BlocErrorHandler, SafeBloc { ClientHomeBloc({ required GetDashboardDataUseCase getDashboardDataUseCase, required GetRecentReordersUseCase getRecentReordersUseCase, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart index 62357ee4..92f741dc 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart @@ -32,8 +32,21 @@ class ClientHomePage extends StatelessWidget { ClientHomeHeader(i18n: i18n), ClientHomeEditBanner(i18n: i18n), Flexible( - child: BlocBuilder( + child: BlocConsumer( + listener: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.error) { + return _buildErrorState(context, state); + } if (state.isEditMode) { return _buildEditModeList(context, state); } @@ -108,4 +121,33 @@ class ClientHomePage extends StatelessWidget { }, ); } + + Widget _buildErrorState(BuildContext context, ClientHomeState state) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.error, + size: 48, + color: UiColors.error, + ), + const SizedBox(height: UiConstants.space4), + Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space4), + UiButton.secondary( + text: 'Retry', + onPressed: () => + BlocProvider.of(context).add(ClientHomeStarted()), + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index 78751548..eb93cb7e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -34,18 +34,21 @@ class ClientHubsPage extends StatelessWidget { }, listener: (BuildContext context, ClientHubsState state) { if (state.errorMessage != null && state.errorMessage!.isNotEmpty) { - final String errorMessage = translateErrorKey(state.errorMessage!); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage)), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, ); BlocProvider.of( context, ).add(const ClientHubsMessageCleared()); } if (state.successMessage != null && state.successMessage!.isNotEmpty) { - ScaffoldMessenger.of( + UiSnackbar.show( context, - ).showSnackBar(SnackBar(content: Text(state.successMessage!))); + message: state.successMessage!, + type: UiSnackbarType.success, + ); BlocProvider.of( context, ).add(const ClientHubsMessageCleared()); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart index a0be904f..8c59e977 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart @@ -52,6 +52,8 @@ class _AddHubDialogState extends State { super.dispose(); } + final GlobalKey _formKey = GlobalKey(); + @override Widget build(BuildContext context) { return Container( @@ -68,66 +70,85 @@ class _AddHubDialogState extends State { BoxShadow(color: UiColors.popupShadow, blurRadius: 20), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.client_hubs.add_hub_dialog.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space5), - _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), - TextField( - controller: _nameController, - style: UiTypography.body1r.textPrimary, - decoration: _buildInputDecoration( - t.client_hubs.add_hub_dialog.name_hint, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.client_hubs.add_hub_dialog.title, + style: UiTypography.headline3m.textPrimary, ), - ), - const SizedBox(height: UiConstants.space4), - _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.add_hub_dialog.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, - ), - const SizedBox(height: UiConstants.space8), - Row( - children: [ - Expanded( - child: UiButton.secondary( - onPressed: widget.onCancel, - text: t.common.cancel, - ), + const SizedBox(height: UiConstants.space5), + _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: UiButton.primary( - onPressed: () { - if (_nameController.text.isNotEmpty) { - widget.onCreate( - _nameController.text, - _addressController.text, - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse( - _selectedPrediction?.lat ?? '', - ), - longitude: double.tryParse( - _selectedPrediction?.lng ?? '', - ), - ); - } - }, - text: t.client_hubs.add_hub_dialog.create_button, + ), + const SizedBox(height: UiConstants.space4), + _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), + // Assuming HubAddressAutocomplete is a custom widget wrapper. + // If it doesn't expose a validator, we might need to modify it or manually check _addressController. + // For now, let's just make sure we validate name. Address is tricky if it's a wrapper. + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + const SizedBox(height: UiConstants.space8), + Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: widget.onCancel, + text: t.common.cancel, + ), ), - ), - ], - ), - ], + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + onPressed: () { + if (_formKey.currentState!.validate()) { + // Manually check address if needed, or assume manual entry is ok. + if (_addressController.text.trim().isEmpty) { + // Show manual error or scaffold + UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); + return; + } + + widget.onCreate( + _nameController.text, + _addressController.text, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: t.client_hubs.add_hub_dialog.create_button, + ), + ), + ], + ), + ], + ), ), ), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index a9c6fdc0..44977e55 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -2,6 +2,8 @@ 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:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import '../blocs/client_settings_bloc.dart'; import '../widgets/client_settings_page/settings_actions.dart'; @@ -24,15 +26,19 @@ class ClientSettingsPage extends StatelessWidget { child: BlocListener( listener: (BuildContext context, ClientSettingsState state) { if (state is ClientSettingsSignOutSuccess) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Signed out successfully')), + UiSnackbar.show( + context, + message: 'Signed out successfully', + type: UiSnackbarType.success, ); Modular.to.toClientRoot(); } if (state is ClientSettingsError) { - ScaffoldMessenger.of( + UiSnackbar.show( context, - ).showSnackBar(SnackBar(content: Text(state.message))); + message: translateErrorKey(state.message), + type: UiSnackbarType.error, + ); } }, child: const Scaffold( diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart index 79a136bd..81c3ba32 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_cubit.dart @@ -62,7 +62,10 @@ class ViewOrdersCubit extends Cubit ); _updateDerivedState(); }, - onError: (String _) => state.copyWith(status: ViewOrdersStatus.failure), + onError: (String message) => state.copyWith( + status: ViewOrdersStatus.failure, + errorMessage: message, + ), ); } diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart index af67fa19..f51cf215 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/blocs/view_orders_state.dart @@ -15,9 +15,11 @@ class ViewOrdersState extends Equatable { this.activeCount = 0, this.completedCount = 0, this.upNextCount = 0, + this.errorMessage, }); final ViewOrdersStatus status; + final String? errorMessage; final List orders; final List filteredOrders; final List calendarDays; @@ -39,9 +41,11 @@ class ViewOrdersState extends Equatable { int? activeCount, int? completedCount, int? upNextCount, + String? errorMessage, }) { return ViewOrdersState( status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, orders: orders ?? this.orders, filteredOrders: filteredOrders ?? this.filteredOrders, calendarDays: calendarDays ?? this.calendarDays, @@ -66,5 +70,6 @@ class ViewOrdersState extends Equatable { activeCount, completedCount, upNextCount, + errorMessage, ]; } diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart index 5000d680..d2f972e8 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -68,7 +68,17 @@ class _ViewOrdersViewState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listener: (BuildContext context, ViewOrdersState state) { + if (state.status == ViewOrdersStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, builder: (BuildContext context, ViewOrdersState state) { final List calendarDays = state.calendarDays; final List filteredOrders = state.filteredOrders; @@ -101,64 +111,66 @@ class _ViewOrdersViewState extends State { // Content List Expanded( - child: filteredOrders.isEmpty - ? _buildEmptyState(context: context, state: state) - : ListView( - padding: const EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space4, - UiConstants.space5, - 100, - ), - children: [ - if (filteredOrders.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox( - width: UiConstants.space2, - ), - Text( - sectionTitle.toUpperCase(), - style: UiTypography.titleUppercase2m - .copyWith( - color: UiColors.textPrimary, - ), - ), - const SizedBox( - width: UiConstants.space1, - ), - Text( - '(${filteredOrders.length})', - style: UiTypography.footnote1r - .copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), + child: state.status == ViewOrdersStatus.failure + ? _buildErrorState(context: context, state: state) + : filteredOrders.isEmpty + ? _buildEmptyState(context: context, state: state) + : ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + 100, ), - ...filteredOrders.map( - (OrderItem order) => Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, + children: [ + if (filteredOrders.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox( + width: UiConstants.space2, + ), + Text( + sectionTitle.toUpperCase(), + style: UiTypography.titleUppercase2m + .copyWith( + color: UiColors.textPrimary, + ), + ), + const SizedBox( + width: UiConstants.space1, + ), + Text( + '(${filteredOrders.length})', + style: UiTypography.footnote1r + .copyWith( + color: UiColors.textSecondary, + ), + ), + ], + ), + ), + ...filteredOrders.map( + (OrderItem order) => Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ViewOrderCard(order: order), + ), ), - child: ViewOrderCard(order: order), - ), + ], ), - ], - ), ), ], ), @@ -208,4 +220,36 @@ class _ViewOrdersViewState extends State { if (checkDate == tomorrow) return 'Tomorrow'; return DateFormat('EEE, MMM d').format(date); } + + Widget _buildErrorState({ + required BuildContext context, + required ViewOrdersState state, + }) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.error, + size: 48, + color: UiColors.error, + ), + const SizedBox(height: UiConstants.space4), + Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space4), + UiButton.secondary( + text: 'Retry', + onPressed: () => BlocProvider.of(context) + .jumpToDate(state.selectedDate ?? DateTime.now()), + ), + ], + ), + ); + } } 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 878c1b4b..ceef6f69 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 @@ -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:flutter_bloc/flutter_bloc.dart'; @@ -57,12 +58,11 @@ class _PhoneVerificationPageState extends State { AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.staff_authentication.phone_verification_page.validation_error, - ), - ), + UiSnackbar.show( + context, + message: t.staff_authentication.phone_verification_page.validation_error, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), ); } } @@ -110,14 +110,11 @@ class _PhoneVerificationPageState extends State { final String messageKey = state.errorMessage ?? ''; // Handle specific business logic errors for signup if (messageKey == 'errors.auth.account_exists') { - final ScaffoldMessengerState messenger = - ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text(translateErrorKey(messageKey)), - duration: const Duration(seconds: 5), - ), + UiSnackbar.show( + context, + message: translateErrorKey(messageKey), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), ); Future.delayed(const Duration(seconds: 5), () { if (!mounted) return; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart index 8b0720cc..3ff2fe24 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/profile_setup_page.dart @@ -96,13 +96,11 @@ class _ProfileSetupPageState extends State { if (state.status == ProfileSetupStatus.success) { Modular.to.toStaffHome(); } else if (state.status == ProfileSetupStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage ?? - t.staff_authentication.profile_setup_page.error_occurred, - ), - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? t.staff_authentication.profile_setup_page.error_occurred), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); } }, diff --git a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart index ea6e823c..c68ae129 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart @@ -14,6 +14,7 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { final dc.ExampleConnector _dataConnect; final firebase.FirebaseAuth _firebaseAuth; + String? _cachedStaffId; AvailabilityRepositoryImpl({ required dc.ExampleConnector dataConnect, @@ -22,6 +23,8 @@ class AvailabilityRepositoryImpl _firebaseAuth = firebaseAuth; Future _getStaffId() async { + if (_cachedStaffId != null) return _cachedStaffId!; + final firebase.User? user = _firebaseAuth.currentUser; if (user == null) { throw NotAuthenticatedException( @@ -33,7 +36,8 @@ class AvailabilityRepositoryImpl if (result.data.staffs.isEmpty) { throw const ServerException(technicalMessage: 'Staff profile not found'); } - return result.data.staffs.first.id; + _cachedStaffId = result.data.staffs.first.id; + return _cachedStaffId!; } @override @@ -149,48 +153,51 @@ class AvailabilityRepositoryImpl final Set processedDays = {}; final List resultDays = []; + final List> futures = []; + for (int i = 0; i <= dayCount; i++) { - final DateTime date = start.add(Duration(days: i)); - final dc.DayOfWeek dow = _toBackendDay(date.weekday); - - // Logic to determine if enabled based on type - bool enableDay = false; - if (type == 'all') enableDay = true; - else if (type == 'clear') enableDay = false; - else if (type == 'weekdays') { - enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY); - } else if (type == 'weekends') { - enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY); - } + final DateTime date = start.add(Duration(days: i)); + final dc.DayOfWeek dow = _toBackendDay(date.weekday); - // Only update backend once per DayOfWeek (since it's recurring) - // to avoid redundant calls if range > 1 week. - if (!processedDays.contains(dow)) { - processedDays.add(dow); - - final dc.AvailabilityStatus status = _boolToStatus(enableDay); - - await Future.wait([ - _upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status), - _upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status), - _upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status), - ]); - } - - // Prepare return object - final slots = [ - AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay), - AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay), - AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay), - ]; + // Logic to determine if enabled based on type + bool enableDay = false; + if (type == 'all') { + enableDay = true; + } else if (type == 'clear') { + enableDay = false; + } else if (type == 'weekdays') { + enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY); + } else if (type == 'weekends') { + enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY); + } - resultDays.add(DayAvailability( - date: date, - isAvailable: enableDay, - slots: slots, - )); + // Only update backend once per DayOfWeek (since it's recurring) + if (!processedDays.contains(dow)) { + processedDays.add(dow); + final dc.AvailabilityStatus status = _boolToStatus(enableDay); + + futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status)); + futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status)); + futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status)); + } + + // Prepare return object + final slots = [ + AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay), + AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay), + AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay), + ]; + + resultDays.add(DayAvailability( + date: date, + isAvailable: enableDay, + slots: slots, + )); } + // Execute all updates in parallel + await Future.wait(futures); + return resultDays; }); } diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index bb5fb06e..186511e7 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -42,12 +42,12 @@ class _AvailabilityPageState extends State { @override Widget build(BuildContext context) { - Translations.of(context); + final i18n = Translations.of(context).staff.availability; return BlocProvider.value( value: _bloc, child: Scaffold( appBar: UiAppBar( - title: 'My Availability', + title: i18n.title, centerTitle: false, showBackButton: true, ), @@ -64,17 +64,9 @@ class _AvailabilityPageState extends State { if (state is AvailabilityError) { UiSnackbar.show( context, - message: state.message, + message: translateErrorKey(state.message), type: UiSnackbarType.error, ); - } else if (state is AvailabilityError) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(translateErrorKey(state.message)), - behavior: SnackBarBehavior.floating, - ), - ); } }, child: BlocBuilder( @@ -110,7 +102,12 @@ class _AvailabilityPageState extends State { ), ), if (state.isActionInProgress) - const UiLoadingPage(), // Show loading overlay during actions + Positioned.fill( + child: Container( + color: UiColors.white.withValues(alpha: 0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ), ], ); } else if (state is AvailabilityError) { @@ -128,7 +125,6 @@ class _AvailabilityPageState extends State { ], ), ), - ), ); } return const SizedBox.shrink(); @@ -140,6 +136,7 @@ class _AvailabilityPageState extends State { } Widget _buildQuickSet(BuildContext context) { + final i18n = Translations.of(context).staff.availability; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -150,26 +147,28 @@ class _AvailabilityPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Quick Set Availability', + i18n.quick_set_title, style: UiTypography.body2b, ), const SizedBox(height: UiConstants.space3), Row( children: [ - Expanded(child: _buildQuickSetButton(context, 'All Week', 'all')), - const SizedBox(width: UiConstants.space2), Expanded( - child: _buildQuickSetButton(context, 'Weekdays', 'weekdays'), + child: _buildQuickSetButton(context, i18n.all_week, 'all'), ), const SizedBox(width: UiConstants.space2), Expanded( - child: _buildQuickSetButton(context, 'Weekends', 'weekends'), + child: _buildQuickSetButton(context, i18n.weekdays, 'weekdays'), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: _buildQuickSetButton(context, i18n.weekends, 'weekends'), ), const SizedBox(width: UiConstants.space2), Expanded( child: _buildQuickSetButton( context, - 'Clear All', + i18n.clear_all, 'clear', isDestructive: true, ), @@ -388,7 +387,15 @@ class _AvailabilityPageState extends State { style: UiTypography.title2b, ), Text( - isAvailable ? 'You are available' : 'Not available', + isAvailable + ? Translations.of(context) + .staff + .availability + .available_status + : Translations.of(context) + .staff + .availability + .not_available_status, style: UiTypography.body2r.textSecondary, ), ], @@ -560,6 +567,7 @@ class _AvailabilityPageState extends State { } Widget _buildInfoCard() { + final i18n = Translations.of(context).staff.availability; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -577,11 +585,11 @@ class _AvailabilityPageState extends State { spacing: UiConstants.space1, children: [ Text( - 'Auto-Match uses your availability', + i18n.auto_match_title, style: UiTypography.body2m, ), Text( - "When enabled, you'll only be matched with shifts during your available times.", + i18n.auto_match_description, style: UiTypography.body3r.textSecondary, ), ], 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 59a233f5..87df8371 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 @@ -32,6 +32,7 @@ class _ClockInPageState extends State { @override Widget build(BuildContext context) { + final i18n = Translations.of(context).staff.clock_in; return BlocProvider.value( value: _bloc, child: BlocConsumer( @@ -68,7 +69,7 @@ class _ClockInPageState extends State { return Scaffold( appBar: UiAppBar( titleWidget: Text( - 'Clock In to your Shift', + i18n.title, style: UiTypography.title1m.textPrimary, ), showBackButton: false, @@ -115,7 +116,7 @@ class _ClockInPageState extends State { // Your Activity Header Text( - "Your Activity", + i18n.your_activity, textAlign: TextAlign.start, style: UiTypography.headline4m, ), @@ -161,21 +162,23 @@ class _ClockInPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - shift.id == - selectedShift?.id - ? "SELECTED SHIFT" - : "TODAY'S SHIFT", - style: UiTypography - .titleUppercase4b - .copyWith( - color: shift.id == + Text( + shift.id == selectedShift?.id - ? UiColors.primary - : UiColors - .textSecondary, + ? 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, @@ -237,12 +240,16 @@ class _ClockInPageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - "You're early!", + i18n.early_title, style: UiTypography.body1m.textSecondary, ), const SizedBox(height: UiConstants.space1), Text( - "Check-in available at ${_getCheckInAvailabilityTime(selectedShift)}", + i18n.check_in_at( + time: _getCheckInAvailabilityTime( + selectedShift, + ), + ), style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), @@ -316,12 +323,12 @@ class _ClockInPageState extends State { ), const SizedBox(height: UiConstants.space3), Text( - "Shift Completed!", + i18n.shift_completed, style: UiTypography.body1b.textSuccess, ), const SizedBox(height: UiConstants.space1), Text( - "Great work today", + i18n.great_work, style: UiTypography.body2r.textSuccess, ), ], @@ -339,13 +346,13 @@ class _ClockInPageState extends State { child: Column( children: [ Text( - "No confirmed shifts for today", + i18n.no_shifts_today, style: UiTypography.body1m.textSecondary, textAlign: TextAlign.center, ), const SizedBox(height: UiConstants.space1), Text( - "Accept a shift to clock in", + i18n.accept_shift_cta, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), @@ -377,7 +384,7 @@ class _ClockInPageState extends State { CrossAxisAlignment.start, children: [ Text( - "Checked in at", + i18n.checked_in_at_label, style: UiTypography.body3m.textSuccess, ), Text( @@ -472,6 +479,7 @@ class _ClockInPageState extends State { } Future _showNFCDialog(BuildContext context) async { + final i18n = Translations.of(context).staff.clock_in; bool scanned = false; // Using a local navigator context since we are in a dialog @@ -482,7 +490,11 @@ class _ClockInPageState extends State { return StatefulBuilder( builder: (BuildContext context, setState) { return AlertDialog( - title: Text(scanned ? 'Tag Scanned!' : 'Scan NFC Tag'), + title: Text( + scanned + ? i18n.nfc_dialog.scanned_title + : i18n.nfc_dialog.scan_title, + ), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -503,14 +515,16 @@ class _ClockInPageState extends State { ), const SizedBox(height: UiConstants.space6), Text( - scanned ? 'Processing check-in...' : 'Ready to scan', + scanned + ? i18n.nfc_dialog.processing + : i18n.nfc_dialog.ready_to_scan, style: UiTypography.headline4m, ), const SizedBox(height: UiConstants.space2), Text( scanned - ? 'Please wait...' - : 'Hold your phone near the NFC tag at the clock-in station', + ? i18n.nfc_dialog.please_wait + : i18n.nfc_dialog.scan_instruction, textAlign: TextAlign.center, style: UiTypography.body2r.textSecondary, ), @@ -538,7 +552,7 @@ class _ClockInPageState extends State { }, icon: const Icon(UiIcons.nfc, size: 24), label: Text( - 'Tap to Scan', + i18n.nfc_dialog.tap_to_scan, style: UiTypography.headline4m.white, ), style: ElevatedButton.styleFrom( @@ -608,7 +622,8 @@ class _ClockInPageState extends State { final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); return DateFormat('h:mm a').format(windowStart); } catch (e) { - return 'soon'; + final i18n = Translations.of(context).staff.clock_in; + return i18n.soon; } } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart index e251b6cb..bc1ddc3a 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/commute_tracker.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'; @@ -131,6 +132,7 @@ class _CommuteTrackerState extends State { @override Widget build(BuildContext context) { final CommuteMode mode = _getAppMode(); + final i18n = Translations.of(context).staff.clock_in.commute; // Notify parent of mode change WidgetsBinding.instance.addPostFrameCallback((_) { @@ -142,20 +144,20 @@ class _CommuteTrackerState extends State { return const SizedBox.shrink(); case CommuteMode.needsConsent: - return _buildConsentCard(); + return _buildConsentCard(i18n); case CommuteMode.preShiftCommuteAllowed: - return _buildPreShiftCard(); + return _buildPreShiftCard(i18n); case CommuteMode.commuteModeActive: - return _buildActiveCommuteScreen(); + return _buildActiveCommuteScreen(i18n); case CommuteMode.arrivedCanClockIn: - return _buildArrivedCard(); + return _buildArrivedCard(i18n); } } - Widget _buildConsentCard() { + Widget _buildConsentCard(TranslationsStaffClockInCommuteEn i18n) { return Container( margin: const EdgeInsets.only(bottom: UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space3), @@ -202,12 +204,12 @@ class _CommuteTrackerState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Enable Commute Tracking?', + i18n.enable_title, style: UiTypography.body2m.textPrimary, ), const SizedBox(height: UiConstants.space1), Text( - 'Share location 1hr before shift so your manager can see you\'re on the way.', + i18n.enable_desc, style: UiTypography.body4r.textSecondary, ), ], @@ -229,7 +231,7 @@ class _CommuteTrackerState extends State { ), side: const BorderSide(color: UiColors.border), ), - child: Text('Not Now', style: UiTypography.footnote1m), + child: Text(i18n.not_now, style: UiTypography.footnote1m), ), ), const SizedBox(width: UiConstants.space2), @@ -245,7 +247,7 @@ class _CommuteTrackerState extends State { ), ), child: Text( - 'Enable', + i18n.enable, style: UiTypography.footnote1m.white, ), ), @@ -257,7 +259,7 @@ class _CommuteTrackerState extends State { ); } - Widget _buildPreShiftCard() { + Widget _buildPreShiftCard(TranslationsStaffClockInCommuteEn i18n) { return Container( margin: const EdgeInsets.only(bottom: UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space3), @@ -295,7 +297,7 @@ class _CommuteTrackerState extends State { Row( children: [ Text( - 'On My Way', + i18n.on_my_way, style: UiTypography.body2m.textPrimary, ), const SizedBox(width: UiConstants.space2), @@ -308,7 +310,7 @@ class _CommuteTrackerState extends State { ), const SizedBox(width: 2), Text( - 'Shift starts in ${_getMinutesUntilShift()} min', + i18n.starts_in(min: _getMinutesUntilShift().toString()), style: UiTypography.titleUppercase4m.textSecondary, ), ], @@ -316,7 +318,7 @@ class _CommuteTrackerState extends State { ], ), Text( - 'Track arrival', + i18n.track_arrival, style: UiTypography.titleUppercase4m.textSecondary, ), ], @@ -335,7 +337,7 @@ class _CommuteTrackerState extends State { ); } - Widget _buildActiveCommuteScreen() { + Widget _buildActiveCommuteScreen(TranslationsStaffClockInCommuteEn i18n) { return Container( height: MediaQuery.of(context).size.height, decoration: const BoxDecoration( @@ -353,11 +355,11 @@ class _CommuteTrackerState extends State { children: [ Expanded( child: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ TweenAnimationBuilder( tween: Tween(begin: 1.0, end: 1.1), duration: const Duration(seconds: 1), @@ -387,12 +389,12 @@ class _CommuteTrackerState extends State { ), const SizedBox(height: UiConstants.space6), Text( - 'On My Way', + i18n.on_my_way, style: UiTypography.displayMb.white, ), const SizedBox(height: UiConstants.space2), Text( - 'Your manager can see you\'re heading to the site', + i18n.heading_to_site, style: UiTypography.body2r.copyWith( color: UiColors.primaryForeground.withValues(alpha: 0.8), ), @@ -414,7 +416,7 @@ class _CommuteTrackerState extends State { child: Column( children: [ Text( - 'Distance to Site', + i18n.distance_to_site, style: UiTypography.body2r.copyWith( color: UiColors.primaryForeground.withValues(alpha: 0.8), ), @@ -443,14 +445,14 @@ class _CommuteTrackerState extends State { child: Column( children: [ Text( - 'Estimated Arrival', + i18n.estimated_arrival, style: UiTypography.body2r.copyWith( color: UiColors.primaryForeground.withValues(alpha: 0.8), ), ), const SizedBox(height: UiConstants.space1), Text( - '${widget.etaMinutes} min', + i18n.eta_label(min: widget.etaMinutes.toString()), style: UiTypography.headline1m.white, ), ], @@ -460,7 +462,7 @@ class _CommuteTrackerState extends State { ], const SizedBox(height: UiConstants.space8), Text( - 'Most app features are locked while commute mode is on. You\'ll be able to clock in once you arrive.', + i18n.locked_desc, style: UiTypography.footnote1r.copyWith( color: UiColors.primaryForeground.withValues(alpha: 0.8), ), @@ -485,7 +487,7 @@ class _CommuteTrackerState extends State { ), minimumSize: const Size(double.infinity, 48), ), - child: Text('Turn Off Commute Mode', style: UiTypography.buttonL), + child: Text(i18n.turn_off, style: UiTypography.buttonL), ), ), ], @@ -494,7 +496,7 @@ class _CommuteTrackerState extends State { ); } - Widget _buildArrivedCard() { + Widget _buildArrivedCard(TranslationsStaffClockInCommuteEn i18n) { return Container( margin: const EdgeInsets.only(bottom: UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5), @@ -533,12 +535,12 @@ class _CommuteTrackerState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'You\'ve Arrived! 🎉', + i18n.arrived_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'You\'re at the shift location. Ready to clock in?', + i18n.arrived_desc, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart index 83f3d58a..2f29a8f0 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/lunch_break_modal.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -22,13 +23,6 @@ class _LunchBreakDialogState extends State { String _additionalNotes = ''; final List _timeOptions = _generateTimeOptions(); - final List _noLunchReasons = [ - 'Unpredictable Workflows', - 'Poor Time Management', - 'Lack of coverage or short Staff', - 'No Lunch Area', - 'Other (Please specify)', - ]; static List _generateTimeOptions() { final List options = []; @@ -45,6 +39,7 @@ class _LunchBreakDialogState extends State { @override Widget build(BuildContext context) { + final i18n = Translations.of(context).staff.clock_in.lunch_break; return Dialog( backgroundColor: UiColors.white, shape: RoundedRectangleBorder( @@ -52,29 +47,29 @@ class _LunchBreakDialogState extends State { ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: _buildCurrentStep(), + child: _buildCurrentStep(i18n), ), ); } - Widget _buildCurrentStep() { + Widget _buildCurrentStep(TranslationsStaffClockInLunchBreakEn i18n) { switch (_step) { case 1: - return _buildStep1(); + return _buildStep1(i18n); case 2: - return _buildStep2(); + return _buildStep2(i18n); case 102: // 2b: No lunch reason - return _buildStep2b(); + return _buildStep2b(i18n); case 3: - return _buildStep3(); + return _buildStep3(i18n); case 4: - return _buildStep4(); + return _buildStep4(i18n); default: return const SizedBox.shrink(); } } - Widget _buildStep1() { + Widget _buildStep1(TranslationsStaffClockInLunchBreakEn i18n) { return Padding( padding: const EdgeInsets.all(UiConstants.space6), child: Column( @@ -95,7 +90,7 @@ class _LunchBreakDialogState extends State { ), const SizedBox(height: UiConstants.space6), Text( - "Did You Take\na Lunch?", + i18n.title, textAlign: TextAlign.center, style: UiTypography.headline1m.textPrimary, ), @@ -121,7 +116,7 @@ class _LunchBreakDialogState extends State { ), alignment: Alignment.center, child: Text( - "No", + i18n.no, style: UiTypography.body1m.textPrimary, ), ), @@ -146,7 +141,7 @@ class _LunchBreakDialogState extends State { ), ), child: Text( - "Yes", + i18n.yes, style: UiTypography.body1m.white, ), ), @@ -158,7 +153,7 @@ class _LunchBreakDialogState extends State { ); } - Widget _buildStep2() { + Widget _buildStep2(TranslationsStaffClockInLunchBreakEn i18n) { // Time input return Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -166,7 +161,7 @@ class _LunchBreakDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - "When did you take lunch?", + i18n.when_title, style: UiTypography.headline4m, ), const SizedBox(height: UiConstants.space6), @@ -186,9 +181,9 @@ class _LunchBreakDialogState extends State { ) .toList(), onChanged: (String? v) => setState(() => _breakStart = v), - decoration: const InputDecoration( - labelText: 'Start', - contentPadding: EdgeInsets.symmetric( + decoration: InputDecoration( + labelText: i18n.start, + contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), @@ -209,9 +204,9 @@ class _LunchBreakDialogState extends State { ) .toList(), onChanged: (String? v) => setState(() => _breakEnd = v), - decoration: const InputDecoration( - labelText: 'End', - contentPadding: EdgeInsets.symmetric( + decoration: InputDecoration( + labelText: i18n.end, + contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), @@ -230,14 +225,14 @@ class _LunchBreakDialogState extends State { backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), ), - child: Text("Next", style: UiTypography.body1m.white), + child: Text(i18n.next, style: UiTypography.body1m.white), ), ], ), ); } - Widget _buildStep2b() { + Widget _buildStep2b(TranslationsStaffClockInLunchBreakEn i18n) { // No lunch reason return Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -246,11 +241,11 @@ class _LunchBreakDialogState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "Why didn't you take lunch?", + i18n.why_no_lunch, style: UiTypography.headline4m, ), const SizedBox(height: UiConstants.space4), - ..._noLunchReasons.map( + ...i18n.reasons.map( (String reason) => RadioListTile( title: Text(reason, style: UiTypography.body2r), value: reason, @@ -269,14 +264,14 @@ class _LunchBreakDialogState extends State { backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), ), - child: Text("Next", style: UiTypography.body1m.white), + child: Text(i18n.next, style: UiTypography.body1m.white), ), ], ), ); } - Widget _buildStep3() { + Widget _buildStep3(TranslationsStaffClockInLunchBreakEn i18n) { // Additional Notes return Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -284,16 +279,16 @@ class _LunchBreakDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - "Additional Notes", + i18n.additional_notes, style: UiTypography.headline4m, ), const SizedBox(height: UiConstants.space4), TextField( onChanged: (String v) => _additionalNotes = v, style: UiTypography.body2r, - decoration: const InputDecoration( - hintText: 'Add any details...', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: i18n.notes_placeholder, + border: const OutlineInputBorder(), ), maxLines: 3, ), @@ -307,14 +302,14 @@ class _LunchBreakDialogState extends State { backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), ), - child: Text("Submit", style: UiTypography.body1m.white), + child: Text(i18n.submit, style: UiTypography.body1m.white), ), ], ), ); } - Widget _buildStep4() { + Widget _buildStep4(TranslationsStaffClockInLunchBreakEn i18n) { // Success return Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -324,7 +319,7 @@ class _LunchBreakDialogState extends State { const Icon(UiIcons.checkCircle, size: 64, color: UiColors.success), const SizedBox(height: UiConstants.space6), Text( - "Break Logged!", + i18n.success_title, style: UiTypography.headline1m, ), const SizedBox(height: UiConstants.space6), @@ -334,7 +329,7 @@ class _LunchBreakDialogState extends State { backgroundColor: UiColors.primary, minimumSize: const Size(double.infinity, 48), ), - child: Text("Close", style: UiTypography.body1m.white), + child: Text(i18n.close, style: UiTypography.body1m.white), ), ], ), diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart index bb79e4d3..b62120bc 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/swipe_to_check_in.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -71,6 +72,7 @@ class _SwipeToCheckInState extends State @override Widget build(BuildContext context) { + final i18n = Translations.of(context).staff.clock_in.swipe; final Color baseColor = widget.isCheckedIn ? UiColors.success : UiColors.primary; @@ -110,9 +112,9 @@ class _SwipeToCheckInState extends State Text( widget.isLoading ? (widget.isCheckedIn - ? "Checking out..." - : "Checking in...") - : (widget.isCheckedIn ? "NFC Check Out" : "NFC Check In"), + ? i18n.checking_out + : i18n.checking_in) + : (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin), style: UiTypography.body1b.white, ), ], @@ -157,8 +159,8 @@ class _SwipeToCheckInState extends State opacity: 1.0 - progress, child: Text( widget.isCheckedIn - ? "Swipe to Check Out" - : "Swipe to Check In", + ? i18n.swipe_checkout + : i18n.swipe_checkin, style: UiTypography.body1b, ), ), @@ -166,7 +168,7 @@ class _SwipeToCheckInState extends State if (_isComplete) Center( child: Text( - widget.isCheckedIn ? "Check Out!" : "Check In!", + widget.isCheckedIn ? i18n.checkout_complete : i18n.checkin_complete, style: UiTypography.body1b, ), ), diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index c1d76f56..4433a4c1 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -38,14 +38,7 @@ class _PaymentsPageState extends State { backgroundColor: UiColors.background, body: BlocConsumer( listener: (context, state) { - if (state is PaymentsError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(translateErrorKey(state.message)), - behavior: SnackBarBehavior.floating, - ), - ); - } + // Error is already shown on the page itself (lines 53-63), no need for snackbar }, builder: (BuildContext context, PaymentsState state) { if (state is PaymentsLoading) { @@ -67,14 +60,6 @@ class _PaymentsPageState extends State { } return const SizedBox.shrink(); }, - ), - ), - ); - } else if (state is PaymentsLoaded) { - return _buildContent(context, state); - } - return const SizedBox.shrink(); - }, ), ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index afae7d83..12a66f48 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -66,11 +66,10 @@ class StaffProfilePage extends StatelessWidget { Modular.to.toGetStarted(); } else if (state.status == ProfileStatus.error && state.errorMessage != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(translateErrorKey(state.errorMessage!)), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, ); } }, 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 8b81bbc9..6f9778f6 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 @@ -74,11 +74,10 @@ class CertificatesPage extends StatelessWidget { onEditExpiry: () => _showEditExpiryDialog(context, doc), onRemove: () => _showRemoveConfirmation(context, doc), onView: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.staff_certificates.card.opened_snackbar), - duration: const Duration(seconds: 2), - ), + UiSnackbar.show( + context, + message: t.staff_certificates.card.opened_snackbar, + type: UiSnackbarType.success, ); }, )), 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 19affeb5..9caedfc6 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,3 +1,4 @@ +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; @@ -76,6 +77,15 @@ class _FormI9PageState extends State { @override Widget build(BuildContext context) { + final 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}, + ]; + return BlocProvider.value( value: Modular.get(), child: BlocConsumer( @@ -83,34 +93,32 @@ class _FormI9PageState extends State { if (state.status == FormI9Status.success) { // Success view is handled by state check in build or we can navigate } else if (state.status == FormI9Status.failure) { - final ScaffoldMessengerState messenger = - ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text(state.errorMessage ?? 'An error occurred'), - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? 'An error occurred'), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(left: 16, right: 16, bottom: 100), ); } }, builder: (BuildContext context, FormI9State state) { - if (state.status == FormI9Status.success) return _buildSuccessView(); + if (state.status == FormI9Status.success) return _buildSuccessView(i18n); return Scaffold( backgroundColor: UiColors.background, body: Column( children: [ - _buildHeader(context, state), + _buildHeader(context, state, steps, i18n), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space5, vertical: UiConstants.space6, ), - child: _buildCurrentStep(context, state), + child: _buildCurrentStep(context, state, i18n), ), ), - _buildFooter(context, state), + _buildFooter(context, state, steps), ], ), ); @@ -119,7 +127,7 @@ class _FormI9PageState extends State { ); } - Widget _buildSuccessView() { + Widget _buildSuccessView(TranslationsStaffComplianceTaxFormsI9En i18n) { return Scaffold( backgroundColor: UiColors.background, body: Center( @@ -150,12 +158,12 @@ class _FormI9PageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'Form I-9 Submitted!', + i18n.submitted_title, style: UiTypography.headline4m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'Your employment eligibility verification has been submitted.', + i18n.submitted_desc, textAlign: TextAlign.center, style: UiTypography.body2r.textSecondary, ), @@ -175,7 +183,7 @@ class _FormI9PageState extends State { ), elevation: 0, ), - child: const Text('Back to Documents'), + child: Text(Translations.of(context).staff_compliance.tax_forms.w4.back_to_docs), ), ), ], @@ -186,7 +194,12 @@ class _FormI9PageState extends State { ); } - Widget _buildHeader(BuildContext context, FormI9State state) { + Widget _buildHeader( + BuildContext context, + FormI9State state, + List> steps, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { return Container( color: UiColors.primary, padding: const EdgeInsets.only( @@ -213,11 +226,11 @@ class _FormI9PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Form I-9', + i18n.title, style: UiTypography.headline4m.white, ), Text( - 'Employment Eligibility Verification', + i18n.subtitle, style: UiTypography.body3r.copyWith( color: UiColors.white.withValues(alpha: 0.7), ), @@ -228,12 +241,12 @@ class _FormI9PageState extends State { ), const SizedBox(height: UiConstants.space6), Row( - children: _steps + children: steps .asMap() .entries .map((MapEntry> entry) { final int idx = entry.key; - final bool isLast = idx == _steps.length - 1; + final bool isLast = idx == steps.length - 1; return Expanded( child: Row( children: [ @@ -259,14 +272,17 @@ class _FormI9PageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Step ${state.currentStep + 1} of ${_steps.length}', + i18n.step_label( + current: (state.currentStep + 1).toString(), + total: steps.length.toString(), + ), style: UiTypography.body3r.copyWith( color: UiColors.white.withValues(alpha: 0.7), ), ), Expanded( child: Text( - _steps[state.currentStep]['title']!, + steps[state.currentStep]['title']!, textAlign: TextAlign.end, style: UiTypography.body3m.white.copyWith( fontWeight: FontWeight.w500, @@ -280,16 +296,20 @@ class _FormI9PageState extends State { ); } - Widget _buildCurrentStep(BuildContext context, FormI9State state) { + Widget _buildCurrentStep( + BuildContext context, + FormI9State state, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { switch (state.currentStep) { case 0: - return _buildStep1(context, state); + return _buildStep1(context, state, i18n); case 1: - return _buildStep2(context, state); + return _buildStep2(context, state, i18n); case 2: - return _buildStep3(context, state); + return _buildStep3(context, state, i18n); case 3: - return _buildStep4(context, state); + return _buildStep4(context, state, i18n); default: return Container(); } @@ -347,26 +367,30 @@ class _FormI9PageState extends State { ); } - Widget _buildStep1(BuildContext context, FormI9State state) { + Widget _buildStep1( + BuildContext context, + FormI9State state, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { return Column( children: [ Row( children: [ Expanded( child: _buildTextField( - 'First Name *', + i18n.fields.first_name, value: state.firstName, onChanged: (String val) => context.read().firstNameChanged(val), - placeholder: 'John', + placeholder: i18n.fields.hints.first_name, ), ), const SizedBox(width: 12), Expanded( child: _buildTextField( - 'Last Name *', + i18n.fields.last_name, value: state.lastName, onChanged: (String val) => context.read().lastNameChanged(val), - placeholder: 'Smith', + placeholder: i18n.fields.hints.last_name, ), ), ], @@ -376,37 +400,37 @@ class _FormI9PageState extends State { children: [ Expanded( child: _buildTextField( - 'Middle Initial', + i18n.fields.middle_initial, value: state.middleInitial, onChanged: (String val) => context.read().middleInitialChanged(val), - placeholder: 'A', + placeholder: i18n.fields.hints.middle_initial, ), ), const SizedBox(width: 12), Expanded( flex: 2, child: _buildTextField( - 'Other Last Names', + i18n.fields.other_last_names, value: state.otherLastNames, onChanged: (String val) => context.read().otherLastNamesChanged(val), - placeholder: 'Maiden name (if any)', + placeholder: i18n.fields.maiden_name, ), ), ], ), const SizedBox(height: 16), _buildTextField( - 'Date of Birth *', + i18n.fields.dob, value: state.dob, onChanged: (String val) => context.read().dobChanged(val), - placeholder: 'MM/DD/YYYY', + placeholder: i18n.fields.hints.dob, keyboardType: TextInputType.datetime, ), const SizedBox(height: 16), _buildTextField( - 'Social Security Number *', + i18n.fields.ssn, value: state.ssn, - placeholder: 'XXX-XX-XXXX', + placeholder: i18n.fields.hints.ssn, keyboardType: TextInputType.number, onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); @@ -416,39 +440,43 @@ class _FormI9PageState extends State { ), const SizedBox(height: 16), _buildTextField( - 'Email Address', + i18n.fields.email, value: state.email, onChanged: (String val) => context.read().emailChanged(val), keyboardType: TextInputType.emailAddress, - placeholder: 'john.smith@example.com', + placeholder: i18n.fields.hints.email, ), const SizedBox(height: 16), _buildTextField( - 'Phone Number', + i18n.fields.phone, value: state.phone, onChanged: (String val) => context.read().phoneChanged(val), keyboardType: TextInputType.phone, - placeholder: '(555) 555-5555', + placeholder: i18n.fields.hints.phone, ), ], ); } - Widget _buildStep2(BuildContext context, FormI9State state) { + Widget _buildStep2( + BuildContext context, + FormI9State state, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { return Column( children: [ _buildTextField( - 'Address (Street Number and Name) *', + i18n.fields.address_long, value: state.address, onChanged: (String val) => context.read().addressChanged(val), - placeholder: '123 Main Street', + placeholder: i18n.fields.hints.address, ), const SizedBox(height: 16), _buildTextField( - 'Apt. Number', + i18n.fields.apt, value: state.aptNumber, onChanged: (String val) => context.read().aptNumberChanged(val), - placeholder: '4B', + placeholder: i18n.fields.hints.apt, ), const SizedBox(height: 16), Row( @@ -456,10 +484,10 @@ class _FormI9PageState extends State { Expanded( flex: 2, child: _buildTextField( - 'City or Town *', + i18n.fields.city, value: state.city, onChanged: (String val) => context.read().cityChanged(val), - placeholder: 'San Francisco', + placeholder: i18n.fields.hints.city, ), ), const SizedBox(width: 12), @@ -468,7 +496,7 @@ class _FormI9PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'State *', + i18n.fields.state, style: UiTypography.body3m.textSecondary.copyWith( fontWeight: FontWeight.w500, ), @@ -507,22 +535,26 @@ class _FormI9PageState extends State { ), const SizedBox(height: 16), _buildTextField( - 'ZIP Code *', + i18n.fields.zip, value: state.zipCode, onChanged: (String val) => context.read().zipCodeChanged(val), - placeholder: '94103', + placeholder: i18n.fields.hints.zip, keyboardType: TextInputType.number, ), ], ); } - Widget _buildStep3(BuildContext context, FormI9State state) { + Widget _buildStep3( + BuildContext context, + FormI9State state, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'I attest, under penalty of perjury, that I am (check one of the following boxes):', + i18n.fields.attestation, style: UiTypography.body2m.textPrimary, ), const SizedBox(height: UiConstants.space6), @@ -530,29 +562,29 @@ class _FormI9PageState extends State { context, state, 'CITIZEN', - '1. A citizen of the United States', + i18n.fields.citizen, ), const SizedBox(height: 12), _buildRadioOption( context, state, 'NONCITIZEN', - '2. A noncitizen national of the United States', + i18n.fields.noncitizen, ), const SizedBox(height: 12), _buildRadioOption( context, state, 'PERMANENT_RESIDENT', - '3. A lawful permanent resident', + i18n.fields.permanent_resident, child: state.citizenshipStatus == 'PERMANENT_RESIDENT' ? Padding( padding: const EdgeInsets.only(top: 12), child: _buildTextField( - 'USCIS Number', + i18n.fields.uscis_number_label, value: state.uscisNumber, onChanged: (String val) => context.read().uscisNumberChanged(val), - placeholder: 'A-123456789', + placeholder: i18n.fields.hints.uscis, ), ) : null, @@ -562,26 +594,26 @@ class _FormI9PageState extends State { context, state, 'ALIEN', - '4. An alien authorized to work', + i18n.fields.alien, child: state.citizenshipStatus == 'ALIEN' ? Padding( padding: const EdgeInsets.only(top: 12), child: Column( children: [ _buildTextField( - 'USCIS/Admission Number', + i18n.fields.admission_number, value: state.admissionNumber, onChanged: (String val) => context.read().admissionNumberChanged(val), ), const SizedBox(height: 12), _buildTextField( - 'Foreign Passport Number', + i18n.fields.passport, value: state.passportNumber, onChanged: (String val) => context.read().passportNumberChanged(val), ), const SizedBox(height: 12), _buildTextField( - 'Country of Issuance', + i18n.fields.country, value: state.countryIssuance, onChanged: (String val) => context.read().countryIssuanceChanged(val), ), @@ -645,7 +677,11 @@ class _FormI9PageState extends State { ); } - Widget _buildStep4(BuildContext context, FormI9State state) { + Widget _buildStep4( + BuildContext context, + FormI9State state, + TranslationsStaffComplianceTaxFormsI9En i18n, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -660,18 +696,18 @@ class _FormI9PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Summary', + i18n.fields.summary_title, style: UiTypography.headline4m.copyWith(fontSize: 14), ), const SizedBox(height: UiConstants.space3), - _buildSummaryRow('Name', '${state.firstName} ${state.lastName}'), - _buildSummaryRow('Address', '${state.address}, ${state.city}'), + _buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'), + _buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'), _buildSummaryRow( - 'SSN', + i18n.fields.summary_ssn, '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', ), _buildSummaryRow( - 'Citizenship', + i18n.fields.summary_citizenship, _getReadableCitizenship(state.citizenshipStatus), ), ], @@ -685,7 +721,7 @@ class _FormI9PageState extends State { }, contentPadding: EdgeInsets.zero, title: Text( - 'I used a preparer or translator', + i18n.fields.preparer, style: UiTypography.body2r.textPrimary, ), controlAffinity: ListTileControlAffinity.leading, @@ -699,13 +735,13 @@ class _FormI9PageState extends State { borderRadius: UiConstants.radiusLg, ), child: Text( - 'I am aware that federal law provides for imprisonment and/or fines for false statements or use of false documents in connection with the completion of this form.', + i18n.fields.warning, style: UiTypography.body3r.textWarning.copyWith(fontSize: 12), ), ), const SizedBox(height: UiConstants.space6), Text( - 'Signature (type your full name) *', + i18n.fields.signature_label, style: UiTypography.body3m.textSecondary, ), const SizedBox(height: 6), @@ -717,7 +753,7 @@ class _FormI9PageState extends State { onChanged: (String val) => context.read().signatureChanged(val), decoration: InputDecoration( - hintText: 'Type your full name', + hintText: i18n.fields.signature_hint, filled: true, fillColor: UiColors.bgPopup, contentPadding: const EdgeInsets.symmetric( @@ -741,7 +777,7 @@ class _FormI9PageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'Date', + i18n.fields.date_label, style: UiTypography.body3m.textSecondary, ), const SizedBox(height: 6), @@ -788,21 +824,28 @@ class _FormI9PageState extends State { } String _getReadableCitizenship(String status) { + final i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields; switch (status) { case 'CITIZEN': - return 'US Citizen'; + return i18n.status_us_citizen; case 'NONCITIZEN': - return 'Noncitizen National'; + return i18n.status_noncitizen; case 'PERMANENT_RESIDENT': - return 'Permanent Resident'; + return i18n.status_permanent_resident; case 'ALIEN': - return 'Alien Authorized to Work'; + return i18n.status_alien; default: - return 'Unknown'; + return i18n.status_unknown; } } - Widget _buildFooter(BuildContext context, FormI9State state) { + Widget _buildFooter( + BuildContext context, + FormI9State state, + List> steps, + ) { + final i18n = Translations.of(context).staff_compliance.tax_forms.i9; + return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: const BoxDecoration( @@ -837,7 +880,7 @@ class _FormI9PageState extends State { ), const SizedBox(width: 8), Text( - 'Back', + i18n.back, style: UiTypography.body2r.textPrimary, ), ], @@ -878,11 +921,11 @@ class _FormI9PageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - state.currentStep == _steps.length - 1 - ? 'Sign & Submit' - : 'Continue', + state.currentStep == steps.length - 1 + ? i18n.submit + : i18n.kContinue, ), - if (state.currentStep < _steps.length - 1) ...[ + if (state.currentStep < steps.length - 1) ...[ const SizedBox(width: 8), 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 c8969568..3dfb2197 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 @@ -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:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; @@ -122,6 +123,17 @@ class _FormW4PageState extends State { @override Widget build(BuildContext context) { + final 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')}, + ]; + return BlocProvider.value( value: Modular.get(), child: BlocConsumer( @@ -129,31 +141,32 @@ class _FormW4PageState extends State { if (state.status == FormW4Status.success) { // Handled in builder } else if (state.status == FormW4Status.failure) { - final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'An error occurred')), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? 'An error occurred'), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(left: 16, right: 16, bottom: 100), ); } }, builder: (BuildContext context, FormW4State state) { - if (state.status == FormW4Status.success) return _buildSuccessView(); + if (state.status == FormW4Status.success) return _buildSuccessView(i18n); return Scaffold( backgroundColor: UiColors.background, body: Column( children: [ - _buildHeader(context, state), + _buildHeader(context, state, steps, i18n), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: UiConstants.space5, vertical: UiConstants.space6, ), - child: _buildCurrentStep(context, state), + child: _buildCurrentStep(context, state, i18n), ), ), - _buildFooter(context, state), + _buildFooter(context, state, steps), ], ), ); @@ -162,7 +175,7 @@ class _FormW4PageState extends State { ); } - Widget _buildSuccessView() { + Widget _buildSuccessView(TranslationsStaffComplianceTaxFormsW4En i18n) { return Scaffold( backgroundColor: UiColors.background, body: Center( @@ -193,12 +206,12 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'Form W-4 Submitted!', + i18n.submitted_title, style: UiTypography.headline4m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'Your withholding certificate has been submitted to your employer.', + i18n.submitted_desc, textAlign: TextAlign.center, style: UiTypography.body2r.textSecondary, ), @@ -218,7 +231,7 @@ class _FormW4PageState extends State { ), elevation: 0, ), - child: const Text('Back to Documents'), + child: Text(i18n.back_to_docs), ), ), ], @@ -229,7 +242,12 @@ class _FormW4PageState extends State { ); } - Widget _buildHeader(BuildContext context, FormW4State state) { + Widget _buildHeader( + BuildContext context, + FormW4State state, + List> steps, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Container( color: UiColors.primary, padding: const EdgeInsets.only( @@ -256,11 +274,11 @@ class _FormW4PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Form W-4', + i18n.title, style: UiTypography.headline4m.white, ), Text( - 'Employee\'s Withholding Certificate', + i18n.subtitle, style: UiTypography.body3r.copyWith( color: UiColors.white.withValues(alpha: 0.7), ), @@ -271,12 +289,12 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space6), Row( - children: _steps + children: steps .asMap() .entries .map((MapEntry> entry) { final int idx = entry.key; - final bool isLast = idx == _steps.length - 1; + final bool isLast = idx == steps.length - 1; return Expanded( child: Row( children: [ @@ -302,13 +320,16 @@ class _FormW4PageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Step ${state.currentStep + 1} of ${_steps.length}', + i18n.step_label( + current: (state.currentStep + 1).toString(), + total: steps.length.toString(), + ), style: UiTypography.body3r.copyWith( color: UiColors.white.withValues(alpha: 0.7), ), ), Text( - _steps[state.currentStep]['title']!, + steps[state.currentStep]['title']!, style: UiTypography.body3m.white.copyWith( fontWeight: FontWeight.w500, ), @@ -320,20 +341,24 @@ class _FormW4PageState extends State { ); } - Widget _buildCurrentStep(BuildContext context, FormW4State state) { + Widget _buildCurrentStep( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { switch (state.currentStep) { case 0: - return _buildStep1(context, state); + return _buildStep1(context, state, i18n); case 1: - return _buildStep2(context, state); + return _buildStep2(context, state, i18n); case 2: - return _buildStep3(context, state); + return _buildStep3(context, state, i18n); case 3: - return _buildStep4(context, state); + return _buildStep4(context, state, i18n); case 4: - return _buildStep5(context, state); + return _buildStep5(context, state, i18n); case 5: - return _buildStep6(context, state); + return _buildStep6(context, state, i18n); default: return Container(); } @@ -391,35 +416,39 @@ class _FormW4PageState extends State { ); } - Widget _buildStep1(BuildContext context, FormW4State state) { + Widget _buildStep1( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( children: [ Row( children: [ Expanded( child: _buildTextField( - 'First Name *', + i18n.fields.first_name, value: state.firstName, onChanged: (String val) => context.read().firstNameChanged(val), - placeholder: 'John', + placeholder: i18n.fields.placeholder_john, ), ), const SizedBox(width: 12), Expanded( child: _buildTextField( - 'Last Name *', + i18n.fields.last_name, value: state.lastName, onChanged: (String val) => context.read().lastNameChanged(val), - placeholder: 'Smith', + placeholder: i18n.fields.placeholder_smith, ), ), ], ), const SizedBox(height: 16), _buildTextField( - 'Social Security Number *', + i18n.fields.ssn, value: state.ssn, - placeholder: 'XXX-XX-XXXX', + placeholder: i18n.fields.placeholder_ssn, keyboardType: TextInputType.number, onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); @@ -429,23 +458,27 @@ class _FormW4PageState extends State { ), const SizedBox(height: 16), _buildTextField( - 'Address *', + i18n.fields.address, value: state.address, onChanged: (String val) => context.read().addressChanged(val), - placeholder: '123 Main Street', + placeholder: i18n.fields.placeholder_address, ), const SizedBox(height: 16), _buildTextField( - 'City, State, ZIP', + i18n.fields.city_state_zip, value: state.cityStateZip, onChanged: (String val) => context.read().cityStateZipChanged(val), - placeholder: 'San Francisco, CA 94102', + placeholder: i18n.fields.placeholder_csz, ), ], ); } - Widget _buildStep2(BuildContext context, FormW4State state) { + Widget _buildStep2( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( children: [ Container( @@ -460,7 +493,7 @@ class _FormW4PageState extends State { const SizedBox(width: UiConstants.space3), Expanded( child: Text( - 'Your filing status determines your standard deduction and tax rates.', + i18n.fields.filing_info, style: UiTypography.body2r.textPrimary, ), ), @@ -472,7 +505,7 @@ class _FormW4PageState extends State { context, state, 'SINGLE', - 'Single or Married filing separately', + i18n.fields.single, null, ), const SizedBox(height: 12), @@ -480,7 +513,7 @@ class _FormW4PageState extends State { context, state, 'MARRIED', - 'Married filing jointly or Qualifying surviving spouse', + i18n.fields.married, null, ), const SizedBox(height: 12), @@ -488,8 +521,8 @@ class _FormW4PageState extends State { context, state, 'HEAD', - 'Head of household', - 'Check only if you\'re unmarried and pay more than half the costs of keeping up a home', + i18n.fields.head, + i18n.fields.head_desc, ), ], ); @@ -555,7 +588,11 @@ class _FormW4PageState extends State { ); } - Widget _buildStep3(BuildContext context, FormW4State state) { + Widget _buildStep3( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( children: [ Container( @@ -578,12 +615,12 @@ class _FormW4PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'When to complete this step?', + i18n.fields.multiple_jobs_title, style: UiTypography.body2m.accent, ), const SizedBox(height: 4), Text( - 'Complete this step only if you hold more than one job at a time, or are married filing jointly and your spouse also works.', + i18n.fields.multiple_jobs_desc, style: UiTypography.body3r.accent, ), ], @@ -632,12 +669,12 @@ class _FormW4PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'I have multiple jobs or my spouse works', + i18n.fields.multiple_jobs_check, style: UiTypography.body2m.textPrimary, ), const SizedBox(height: 4), Text( - 'Check this box if there are only two jobs total', + i18n.fields.two_jobs_desc, style: UiTypography.body3r.textSecondary, ), ], @@ -649,7 +686,7 @@ class _FormW4PageState extends State { ), const SizedBox(height: 16), Text( - 'If this does not apply, you can continue to the next step', + i18n.fields.multiple_jobs_not_apply, textAlign: TextAlign.center, style: UiTypography.body3r.textSecondary, ), @@ -657,7 +694,11 @@ class _FormW4PageState extends State { ); } - Widget _buildStep4(BuildContext context, FormW4State state) { + Widget _buildStep4( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( children: [ Container( @@ -672,7 +713,7 @@ class _FormW4PageState extends State { const SizedBox(width: UiConstants.space3), Expanded( child: Text( - 'If your total income will be \$200,000 or less (\$400,000 if married filing jointly), you may claim credits for dependents.', + i18n.fields.dependents_info, style: UiTypography.body2r.textPrimary, ), ), @@ -692,8 +733,8 @@ class _FormW4PageState extends State { _buildCounter( context, state, - 'Qualifying children under age 17', - '\$2,000 each', + i18n.fields.children_under_17, + i18n.fields.children_each, (FormW4State s) => s.qualifyingChildren, (int val) => context.read().qualifyingChildrenChanged(val), ), @@ -704,8 +745,8 @@ class _FormW4PageState extends State { _buildCounter( context, state, - 'Other dependents', - '\$500 each', + i18n.fields.other_dependents, + i18n.fields.other_each, (FormW4State s) => s.otherDependents, (int val) => context.read().otherDependentsChanged(val), ), @@ -723,9 +764,9 @@ class _FormW4PageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Total credits (Step 3)', - style: TextStyle( + Text( + i18n.fields.total_credits, + style: const TextStyle( fontWeight: FontWeight.w500, color: Color(0xFF166534), ), @@ -824,56 +865,60 @@ class _FormW4PageState extends State { ); } - Widget _buildStep5(BuildContext context, FormW4State state) { + Widget _buildStep5( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'These adjustments are optional. You can skip them if they don\'t apply.', + i18n.fields.adjustments_info, style: UiTypography.body2r.textSecondary, ), const SizedBox(height: 24), _buildTextField( - '4(a) Other income (not from jobs)', + i18n.fields.other_income, value: state.otherIncome, onChanged: (String val) => context.read().otherIncomeChanged(val), - placeholder: '\$0', + placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), Padding( padding: const EdgeInsets.only(top: 4, bottom: 16), child: Text( - 'Include interest, dividends, retirement income', + i18n.fields.other_income_desc, style: UiTypography.body3r.textSecondary, ), ), _buildTextField( - '4(b) Deductions', + i18n.fields.deductions, value: state.deductions, onChanged: (String val) => context.read().deductionsChanged(val), - placeholder: '\$0', + placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), Padding( padding: const EdgeInsets.only(top: 4, bottom: 16), child: Text( - 'If you expect to claim deductions other than the standard deduction', + i18n.fields.deductions_desc, style: UiTypography.body3r.textSecondary, ), ), _buildTextField( - '4(c) Extra withholding', + i18n.fields.extra_withholding, value: state.extraWithholding, onChanged: (String val) => context.read().extraWithholdingChanged(val), - placeholder: '\$0', + placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), Padding( padding: const EdgeInsets.only(top: 4, bottom: 16), child: Text( - 'Any additional tax you want withheld each pay period', + i18n.fields.extra_withholding_desc, style: UiTypography.body3r.textSecondary, ), ), @@ -881,7 +926,11 @@ class _FormW4PageState extends State { ); } - Widget _buildStep6(BuildContext context, FormW4State state) { + Widget _buildStep6( + BuildContext context, + FormW4State state, + TranslationsStaffComplianceTaxFormsW4En i18n, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -896,25 +945,25 @@ class _FormW4PageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Your W-4 Summary', + i18n.fields.summary_title, style: UiTypography.headline4m.copyWith(fontSize: 14), ), const SizedBox(height: 12), _buildSummaryRow( - 'Name', + i18n.fields.summary_name, '${state.firstName} ${state.lastName}', ), _buildSummaryRow( - 'SSN', + i18n.fields.summary_ssn, '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', ), _buildSummaryRow( - 'Filing Status', + i18n.fields.summary_filing, _getFilingStatusLabel(state.filingStatus), ), if (_totalCredits(state) > 0) _buildSummaryRow( - 'Credits', + i18n.fields.summary_credits, '\$${_totalCredits(state)}', valueColor: Colors.green[700], ), @@ -929,13 +978,13 @@ class _FormW4PageState extends State { borderRadius: UiConstants.radiusLg, ), child: Text( - 'Under penalties of perjury, I declare that this certificate, to the best of my knowledge and belief, is true, correct, and complete.', + i18n.fields.perjury_declaration, style: UiTypography.body3r.textWarning.copyWith(fontSize: 12), ), ), const SizedBox(height: UiConstants.space6), Text( - 'Signature (type your full name) *', + i18n.fields.signature_label, style: UiTypography.body3m.textSecondary, ), const SizedBox(height: 6), @@ -947,7 +996,7 @@ class _FormW4PageState extends State { onChanged: (String val) => context.read().signatureChanged(val), decoration: InputDecoration( - hintText: 'Type your full name', + hintText: i18n.fields.signature_hint, filled: true, fillColor: UiColors.bgPopup, contentPadding: const EdgeInsets.symmetric( @@ -971,7 +1020,7 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'Date', + i18n.fields.date_label, style: UiTypography.body3m.textSecondary, ), const SizedBox(height: 6), @@ -1017,19 +1066,26 @@ class _FormW4PageState extends State { } String _getFilingStatusLabel(String status) { + final i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields; switch (status) { - case 'single': - return 'Single'; - case 'married': - return 'Married'; - case 'head_of_household': - return 'Head of Household'; + case 'SINGLE': + return i18n.status_single; + case 'MARRIED': + return i18n.status_married; + case 'HEAD': + return i18n.status_head; default: return status; } } - Widget _buildFooter(BuildContext context, FormW4State state) { + Widget _buildFooter( + BuildContext context, + FormW4State state, + List> steps, + ) { + final i18n = Translations.of(context).staff_compliance.tax_forms.w4; + return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: const BoxDecoration( @@ -1064,7 +1120,7 @@ class _FormW4PageState extends State { ), const SizedBox(width: 8), Text( - 'Back', + i18n.fields.back, style: UiTypography.body2r.textPrimary, ), ], @@ -1105,11 +1161,11 @@ class _FormW4PageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - state.currentStep == _steps.length - 1 - ? 'Submit Form' - : 'Continue', + state.currentStep == steps.length - 1 + ? i18n.fields.submit + : i18n.fields.kContinue, ), - if (state.currentStep < _steps.length - 1) ...[ + if (state.currentStep < steps.length - 1) ...[ const SizedBox(width: 8), const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), ], 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 a3fcb942..3c6df18e 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 @@ -48,29 +48,14 @@ class BankAccountPage extends StatelessWidget { bloc: cubit, listener: (BuildContext context, BankAccountState state) { if (state.status == BankAccountStatus.accountAdded) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - strings.account_added_success, - style: UiTypography.body2r.textPrimary, - ), - backgroundColor: UiColors.tagSuccess, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 3), - ), - ); - } else if (state.status == BankAccountStatus.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - ), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: strings.account_added_success, + type: UiSnackbarType.success, + margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16), ); } + // 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) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart index 4858511d..643a234b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/add_account_form.dart @@ -19,6 +19,25 @@ class _AddAccountFormState extends State { final TextEditingController _routingController = TextEditingController(); final TextEditingController _accountController = TextEditingController(); String _selectedType = 'CHECKING'; + bool _isFormValid = false; + + @override + void initState() { + super.initState(); + _bankNameController.addListener(_validateForm); + _routingController.addListener(_validateForm); + _accountController.addListener(_validateForm); + } + + void _validateForm() { + setState(() { + _isFormValid = _bankNameController.text.trim().isNotEmpty && + _routingController.text.trim().isNotEmpty && + _routingController.text.replaceAll(RegExp(r'\D'), '').length == 9 && + _accountController.text.trim().isNotEmpty && + _accountController.text.replaceAll(RegExp(r'\D'), '').length >= 4; + }); + } @override void dispose() { @@ -96,14 +115,16 @@ class _AddAccountFormState extends State { Expanded( child: UiButton.primary( text: widget.strings.save, - onPressed: () { - widget.onSubmit( - _bankNameController.text, - _routingController.text, - _accountController.text, - _selectedType, - ); - }, + onPressed: _isFormValid + ? () { + widget.onSubmit( + _bankNameController.text.trim(), + _routingController.text.trim(), + _accountController.text.trim(), + _selectedType, + ); + } + : null, ), ), ], 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 820073c0..f4a2aaf7 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 @@ -51,13 +51,10 @@ class _TimeCardPageState extends State { body: BlocConsumer( listener: (context, state) { if (state is TimeCardError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - translateErrorKey(state.message), - ), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, ); } }, 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 44cde53f..88af9933 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 @@ -42,8 +42,11 @@ class AttirePage extends StatelessWidget { body: BlocConsumer( listener: (BuildContext context, AttireState state) { if (state.status == AttireStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'Error')), + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? 'Error'), + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); } if (state.status == AttireStatus.saved) { 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 c64ca751..c8aab7be 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 @@ -42,15 +42,13 @@ class EmergencyContactScreen extends StatelessWidget { listener: (context, state) { if (state.status == EmergencyContactStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - ), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); } }, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart index 5f219cad..825e32fa 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart @@ -16,14 +16,11 @@ class EmergencyContactSaveButton extends StatelessWidget { listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { if (state.status == EmergencyContactStatus.saved) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Emergency contacts saved successfully', - style: UiTypography.body2r.textPrimary, - ), - backgroundColor: UiColors.iconSuccess, - ), + UiSnackbar.show( + context, + message: 'Emergency contacts saved successfully', + type: UiSnackbarType.success, + margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); } }, 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 80a5e077..4211c2bd 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 @@ -58,20 +58,21 @@ class ExperiencePage extends StatelessWidget { child: BlocConsumer( listener: (context, state) { if (state.status == ExperienceStatus.success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Experience saved successfully')), + UiSnackbar.show( + context, + message: 'Experience saved successfully', + type: UiSnackbarType.success, + margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16), ); Modular.to.pop(); } else if (state.status == ExperienceStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - ), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16), ); } }, 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 c57c7287..b3d4a8b2 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 @@ -14,7 +14,7 @@ import 'personal_info_state.dart'; /// during onboarding or profile editing. It delegates business logic to /// use cases following Clean Architecture principles. class PersonalInfoBloc extends Bloc - with BlocErrorHandler + with BlocErrorHandler, SafeBloc implements Disposable { /// Creates a [PersonalInfoBloc]. /// @@ -54,8 +54,8 @@ class PersonalInfoBloc extends Bloc 'phone': staff.phone, 'preferredLocations': staff.address != null - ? [staff.address] - : [], // TODO: Map correctly when Staff entity supports list + ? [staff.address!] + : [], // TODO: Map correctly when Staff entity supports list 'avatar': staff.avatar, }; @@ -109,8 +109,8 @@ class PersonalInfoBloc extends Bloc 'phone': updatedStaff.phone, 'preferredLocations': updatedStaff.address != null - ? [updatedStaff.address] - : [], + ? [updatedStaff.address!] + : [], 'avatar': updatedStaff.avatar, }; 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 c1700848..9349ffdb 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 @@ -28,24 +28,19 @@ class PersonalInfoPage extends StatelessWidget { child: BlocListener( listener: (BuildContext context, PersonalInfoState state) { if (state.status == PersonalInfoStatus.saved) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(i18n.save_success), - duration: const Duration(seconds: 2), - ), + UiSnackbar.show( + context, + message: i18n.save_success, + type: UiSnackbarType.success, ); Modular.to.pop(); } else if (state.status == PersonalInfoStatus.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - ), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 3), - ), + UiSnackbar.show( + context, + message: state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + type: UiSnackbarType.error, ); } }, 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 1b04a87c..819293cc 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 @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; // Re-added for UiIcons/Colors as they are used in expanded logic import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -136,20 +137,18 @@ class _ShiftDetailsPageState extends State { } if (state is ShiftActionSuccess) { _isApplying = false; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: UiColors.success, - ), + UiSnackbar.show( + context, + message: state.message, + type: UiSnackbarType.success, ); Modular.to.toShifts(selectedDate: state.shiftDate); } else if (state is ShiftDetailsError) { if (_isApplying || widget.shift == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: UiColors.destructive, - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, ); } _isApplying = false; @@ -170,9 +169,10 @@ class _ShiftDetailsPageState extends State { displayShift = widget.shift; } + final i18n = Translations.of(context).staff_shifts.shift_details; if (displayShift == null) { - return const Scaffold( - body: Center(child: Text("Shift not found")), + return Scaffold( + body: Center(child: Text(Translations.of(context).staff_shifts.list.no_shifts)), ); } @@ -202,7 +202,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "VENDOR", + i18n.vendor, style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), @@ -245,7 +245,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "SHIFT DATE", + i18n.shift_date, style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), @@ -284,7 +284,7 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(width: UiConstants.space2), Text( - "$openSlots slots remaining", + i18n.slots_remaining(count: openSlots), style: UiTypography.footnote1m.textSuccess, ), ], @@ -298,14 +298,14 @@ class _ShiftDetailsPageState extends State { children: [ Expanded( child: _buildTimeBox( - "START TIME", + i18n.start_time, displayShift.startTime, ), ), const SizedBox(width: UiConstants.space4), Expanded( child: _buildTimeBox( - "END TIME", + i18n.end_time, displayShift.endTime, ), ), @@ -320,15 +320,15 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.dollar, "\$${displayShift.hourlyRate.toStringAsFixed(0)}/hr", - "Base Rate", + i18n.base_rate, ), ), const SizedBox(width: UiConstants.space4), Expanded( child: _buildStatCard( UiIcons.clock, - "${duration.toInt()} hours", - "Duration", + i18n.hours_label(count: duration.toInt()), + i18n.duration, ), ), const SizedBox(width: UiConstants.space4), @@ -336,7 +336,7 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.wallet, "\$${estimatedTotal.toStringAsFixed(0)}", - "Est. Total", + i18n.est_total, ), ), ], @@ -348,7 +348,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "LOCATION", + i18n.location, style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space3), @@ -396,7 +396,7 @@ class _ShiftDetailsPageState extends State { UiIcons.arrowRight, size: 16, ), - label: const Text("Open in Maps"), + label: Text(i18n.open_in_maps), style: TextButton.styleFrom( foregroundColor: UiColors.primary, padding: EdgeInsets.zero, @@ -412,7 +412,7 @@ class _ShiftDetailsPageState extends State { // Description / Instructions if ((displayShift.description ?? '').isNotEmpty) ...[ Text( - "JOB DESCRIPTION", + i18n.job_description, style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), @@ -460,15 +460,16 @@ class _ShiftDetailsPageState extends State { BuildContext context, Shift shift, ) { + final i18n = Translations.of(context).staff_shifts.shift_details.book_dialog; showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Book Shift'), - content: const Text('Do you want to instantly book this shift?'), + title: Text(i18n.title), + content: Text(i18n.message), actions: [ TextButton( onPressed: () => Modular.to.pop(), - child: const Text('Cancel'), + child: Text(Translations.of(context).common.cancel), ), TextButton( onPressed: () { @@ -485,7 +486,7 @@ class _ShiftDetailsPageState extends State { style: TextButton.styleFrom( foregroundColor: UiColors.success, ), - child: const Text('Book'), + child: Text(Translations.of(context).staff_shifts.shift_details.apply_now), ), ], ), @@ -493,17 +494,16 @@ class _ShiftDetailsPageState extends State { } void _declineShift(BuildContext context, String id) { + final i18n = Translations.of(context).staff_shifts.shift_details.decline_dialog; showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Decline Shift'), - content: const Text( - 'Are you sure you want to decline this shift? It will be hidden from your available jobs.', - ), + title: Text(i18n.title), + content: Text(i18n.message), actions: [ TextButton( onPressed: () => Modular.to.pop(), - child: const Text('Cancel'), + child: Text(Translations.of(context).common.cancel), ), TextButton( onPressed: () { @@ -514,7 +514,7 @@ class _ShiftDetailsPageState extends State { style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: const Text('Decline'), + child: Text(Translations.of(context).staff_shifts.shift_details.decline), ), ], ), @@ -525,12 +525,13 @@ class _ShiftDetailsPageState extends State { if (_actionDialogOpen) return; _actionDialogOpen = true; _isApplying = true; + final i18n = Translations.of(context).staff_shifts.shift_details.applying_dialog; showDialog( context: context, useRootNavigator: true, barrierDismissible: false, builder: (ctx) => AlertDialog( - title: const Text('Applying'), + title: Text(i18n.title), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -576,6 +577,7 @@ class _ShiftDetailsPageState extends State { Widget _buildBottomButton(Shift shift, BuildContext context) { final String status = shift.status ?? 'open'; + final i18n = Translations.of(context).staff_shifts.shift_details; if (status == 'confirmed') { return Row( children: [ @@ -591,7 +593,7 @@ class _ShiftDetailsPageState extends State { ), elevation: 0, ), - child: Text("CANCEL SHIFT", style: UiTypography.body2b.white), + child: Text(i18n.cancel_shift, style: UiTypography.body2b.white), ), ), const SizedBox(width: UiConstants.space4), @@ -607,7 +609,7 @@ class _ShiftDetailsPageState extends State { ), elevation: 0, ), - child: Text("CLOCK IN", style: UiTypography.body2b.white), + child: Text(i18n.clock_in, style: UiTypography.body2b.white), ), ), ], @@ -628,7 +630,7 @@ class _ShiftDetailsPageState extends State { borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), ), - child: Text("DECLINE", style: UiTypography.body2b.textError), + child: Text(i18n.decline, style: UiTypography.body2b.textError), ), ), const SizedBox(width: UiConstants.space4), @@ -644,7 +646,7 @@ class _ShiftDetailsPageState extends State { ), elevation: 0, ), - child: Text("ACCEPT SHIFT", style: UiTypography.body2b.white), + child: Text(i18n.accept_shift, style: UiTypography.body2b.white), ), ), ], @@ -665,7 +667,7 @@ class _ShiftDetailsPageState extends State { borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), ), - child: Text("DECLINE", style: UiTypography.body2b.textSecondary), + child: Text(i18n.decline, style: UiTypography.body2b.textSecondary), ), ), const SizedBox(width: UiConstants.space4), @@ -681,7 +683,7 @@ class _ShiftDetailsPageState extends State { ), elevation: 0, ), - child: Text("APPLY NOW", style: UiTypography.body2b.white), + child: Text(i18n.apply_now, style: UiTypography.body2b.white), ), ), ], @@ -692,15 +694,16 @@ class _ShiftDetailsPageState extends State { } void _openCancelDialog(BuildContext context) { + final i18n = Translations.of(context).staff_shifts.shift_details.cancel_dialog; showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Cancel Shift'), - content: const Text('Are you sure you want to cancel this shift?'), + title: Text(i18n.title), + content: Text(i18n.message), actions: [ TextButton( onPressed: () => Modular.to.pop(), - child: const Text('No'), + child: Text(Translations.of(context).common.cancel), ), TextButton( onPressed: () { @@ -712,7 +715,7 @@ class _ShiftDetailsPageState extends State { style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: const Text('Yes, cancel it'), + child: Text(Translations.of(context).common.ok), ), ], ), 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 fb32be0c..992505fe 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 @@ -72,11 +72,10 @@ class _ShiftsPageState extends State { child: BlocConsumer( listener: (context, state) { if (state is ShiftsError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(translateErrorKey(state.message)), - behavior: SnackBarBehavior.floating, - ), + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, ); } }, 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 f184df7c..cd5694d4 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:design_system/design_system.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../blocs/shifts/shifts_bloc.dart'; import '../my_shift_card.dart'; @@ -115,11 +116,10 @@ class _MyShiftsTabState extends State { onPressed: () { Navigator.of(context).pop(); context.read().add(AcceptShiftEvent(id)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Shift confirmed!'), - backgroundColor: UiColors.success, - ), + UiSnackbar.show( + context, + message: 'Shift confirmed!', + type: UiSnackbarType.success, ); }, style: TextButton.styleFrom( @@ -149,11 +149,10 @@ class _MyShiftsTabState extends State { onPressed: () { Navigator.of(context).pop(); context.read().add(DeclineShiftEvent(id)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Shift declined.'), - backgroundColor: UiColors.destructive, - ), + UiSnackbar.show( + context, + message: 'Shift declined.', + type: UiSnackbarType.error, ); }, style: TextButton.styleFrom( diff --git a/docs/ERROR_HANDLING_ARCHITECTURE.md b/docs/ERROR_HANDLING_ARCHITECTURE.md index 5f809728..095f350c 100644 --- a/docs/ERROR_HANDLING_ARCHITECTURE.md +++ b/docs/ERROR_HANDLING_ARCHITECTURE.md @@ -48,54 +48,95 @@ graph TD ``` ### 1. The Data Layer (The Guard) -* **Role:** Wraps all API calls. -* **Mechanism:** Catches raw errors (SocketException, FirebaseException) and converts them into domain-specific `AppExceptions` (e.g., `NetworkException`). -* **Location:** `packages/data_connect/lib/src/mixins/data_error_handler.dart` +**Location:** `packages/data_connect/lib/src/mixins/data_error_handler.dart` -### 2. The BLoC Layer (The Logic) -* **Role:** Manages state. -* **Mechanism:** Uses `handleError` helper to execute logic. If an `AppException` bubbles up, it automatically emits a failure state with a **Message Key** (e.g., `errors.auth.session_expired`). -* **Location:** `packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` +This is where raw exceptions (from Firebase, network, etc.) are caught and converted into **typed exceptions** that the rest of the app can understand. -### 3. The UI Layer (The Presenter) -* **Role:** Shows the message. -* **Mechanism:** Observes the state. When an error occurs, it passes the key to `translateErrorKey(key)` which returns the user-friendly text from the active language file (e.g., `es.i18n.json`). -* **Location:** `packages/core_localization/lib/src/utils/error_translator.dart` +**Example:** +```dart +mixin DataErrorHandler { + Future handleDataOperation(Future Function() operation) async { + try { + return await operation(); + } on SocketException { + throw NetworkException('errors.network.no_connection'); + } on HttpException catch (e) { + if (e.statusCode == 500) throw ServerException('errors.server.internal'); + if (e.statusCode == 404) throw NotFoundException('errors.not_found.resource'); + // ... more mappings + } + } +} +``` + +### 2. The BLoC Layer (The Translator) +**Location:** `packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +BLoCs use this mixin to catch exceptions and convert them into **error keys** that can be localized. + +**Example:** +```dart +mixin BlocErrorHandler { + String handleError(Object error) { + if (error is NetworkException) return error.message; + if (error is ServerException) return error.message; + return 'errors.unknown'; + } +} +``` + +### 3. The UI Layer (The Messenger) +**Location:** `packages/core_localization/lib/src/utils/error_translator.dart` + +The UI calls `translateErrorKey()` to convert error keys into user-friendly, localized messages. + +**Example:** +```dart +String translateErrorKey(String key) { + final t = LocaleSettings.instance.currentTranslations; + return t[key] ?? 'An error occurred'; +} +``` --- -## 3. Verified Features -The following features have been audited and are confirmed to use the Centralized Error Handler: +## 3. Real-World Example: Submitting a Tax Form -### **✅ Core Infrastructure** -* Authentication (Login/Signup) -* Firebase Data Connect Integration -* Localization Engine +Let's trace what happens when a user submits Form W-4 with no internet: -### **✅ Staff App Features** -| Feature | Status | Notes | -| :--- | :---: | :--- | -| **Shifts** | ✅ | Handles availability, accept, history errors | -| **Profile** | ✅ | Personal info, experience, attire updates | -| **Onboarding** | ✅ | Tax forms (W4/I9), emergency contacts | -| **Compliance** | ✅ | Document uploads, certificate management | -| **Financials** | ✅ | Bank accounts, time cards, payments | -| **Availability** | ✅ | Weekly schedule blocking | -| **Clock In** | ✅ | Location/Time validation errors | - -### **✅ Client App Features** -| Feature | Status | Notes | -| :--- | :---: | :--- | -| **Auth** | ✅ | Login, Signup failures | -| **Create Order** | ✅ | Type selection, validation | -| **View Orders** | ✅ | Loading lists, filtering | -| **Hubs** | ✅ | NFC assignment errors | -| **Billing** | ✅ | Payment methods, invoicing | -| **Coverage** | ✅ | Dashboard metric loading | +1. **User Action:** Clicks "Submit Form" +2. **BLoC:** `FormW4Bloc` calls `submitW4UseCase.call(formData)` +3. **Use Case:** Calls `taxFormsRepository.submitW4(formData)` +4. **Repository (Data Layer):** + ```dart + Future submitW4(W4Data data) async { + return handleDataOperation(() async { + await _api.submitW4(data); // This throws SocketException + }); + } + ``` +5. **DataErrorHandler Mixin:** Catches `SocketException` → throws `NetworkException('errors.network.no_connection')` +6. **BLoC:** Catches `NetworkException` → emits `FormW4State.error(errorMessage: 'errors.network.no_connection')` +7. **UI (FormW4Page):** + ```dart + BlocListener( + listener: (context, state) { + if (state.status == FormW4Status.error) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + ) + ``` +8. **translateErrorKey:** Looks up `'errors.network.no_connection'` in `en.i18n.json` → returns `"No internet connection. Please check your network."` +9. **User Sees:** A friendly snackbar with the localized message --- -## 4. How to Verify (Demo Script) +## 4. Simple Verification Tests (For Non-Developers) ### Test A: The "Tunnel" Test (Network) 1. Open the app to the **Shifts** page. @@ -123,7 +164,193 @@ The following features have been audited and are confirmed to use the Centralize --- -## 5. Code Locations (Reference) +## 5. Comprehensive Testing Guide for Client Verification + +This section provides a complete testing checklist to verify that centralized error handling is working correctly across the entire Staff app. + +### 🎯 **Testing Objectives** +- Verify all errors are caught and handled gracefully (no crashes) +- Confirm error messages are user-friendly and localized +- Ensure consistent error display using `UiSnackbar` +- Validate that error keys are properly translated + +--- + +### 📱 **Test Suite A: Tax Forms (I-9 & W-4)** + +These forms have been fully integrated with centralized error handling and localization. + +#### **Test A1: Form Validation Errors** +1. Navigate to **Profile → Documents → Form W-4** +2. Try to proceed to next step **without filling required fields** +3. **Expected Result:** + - Validation error appears in a snackbar + - Message is clear and specific (e.g., "First name is required") + - Error is localized (Spanish: "Se requiere el nombre") + +#### **Test A2: Network Error During Submission** +1. Fill out **Form I-9** completely +2. **Turn off WiFi/Mobile Data** +3. Click **Submit Form** +4. **Expected Result:** + - Snackbar shows: "No internet connection. Please check your network." + - Spanish: "Sin conexión a internet. Verifica tu red." + - Form data is NOT lost + +#### **Test A3: Server Error Simulation** +1. Fill out **Form W-4** +2. If backend is accessible, trigger a 500 error +3. **Expected Result:** + - Snackbar shows: "Our servers are having issues. Please try again." + - Spanish: "Nuestros servidores tienen problemas. Inténtalo de nuevo." + +#### **Test A4: Language Switching** +1. Navigate to **Form I-9** +2. Trigger any error (validation or network) +3. Note the error message +4. **Change device language to Spanish** (Settings → Language) +5. Trigger the same error +6. **Expected Result:** + - Error message appears in Spanish + - All form labels and hints are also in Spanish + +--- + +### 📱 **Test Suite B: Shifts & Availability** + +#### **Test B1: Network Error on Shifts Page** +1. Navigate to **Shifts** tab +2. **Enable Airplane Mode** +3. Pull to refresh +4. **Expected Result:** + - Snackbar: "No internet connection" + - No crash or blank screen + - Previous data (if any) remains visible + +#### **Test B2: Shift Not Found (404)** +1. If possible, try to access a deleted/non-existent shift +2. **Expected Result:** + - Snackbar: "Shift not found" + - User is redirected back to shifts list + +--- + +### 📱 **Test Suite C: Profile & Authentication** + +#### **Test C1: Session Expiry (401)** +1. Let the app sit idle for extended period (or manually invalidate token) +2. Try to perform any action (update profile, submit form) +3. **Expected Result:** + - Snackbar: "Your session has expired. Please log in again." + - App automatically redirects to login screen + +#### **Test C2: Profile Update Errors** +1. Navigate to **Profile → Personal Info** +2. Try to update with invalid data (e.g., invalid email format) +3. **Expected Result:** + - Validation error in snackbar + - Specific message about what's wrong + +--- + +### 📱 **Test Suite D: Payments & Bank Account** + +#### **Test D1: Bank Account Addition Error** +1. Navigate to **Profile → Bank Account** +2. Try to add account with invalid routing number +3. **Expected Result:** + - Snackbar shows validation error + - Error is localized + +#### **Test D2: Payment History Network Error** +1. Navigate to **Payments** tab +2. **Turn off internet** +3. Try to load payment history +4. **Expected Result:** + - Snackbar: "No internet connection" + - No crash + +--- + +### 📱 **Test Suite E: Clock In & Attendance** + +#### **Test E1: Clock In Network Error** +1. Navigate to **Clock In** tab +2. **Disable network** +3. Try to clock in +4. **Expected Result:** + - Snackbar: "No internet connection" + - Clock in action is blocked until network returns + +--- + +### 🌐 **Test Suite F: Localization Verification** + +#### **Test F1: English Error Messages** +With device language set to **English**, verify these error keys translate correctly: + +| Scenario | Expected English Message | +|----------|-------------------------| +| No internet | "No internet connection. Please check your network." | +| Server error (500) | "Our servers are having issues. Please try again." | +| Not found (404) | "The requested resource was not found." | +| Unauthorized (401) | "Your session has expired. Please log in again." | +| Validation error | Specific field error (e.g., "Email is required") | + +#### **Test F2: Spanish Error Messages** +With device language set to **Español**, verify these error keys translate correctly: + +| Scenario | Expected Spanish Message | +|----------|-------------------------| +| No internet | "Sin conexión a internet. Verifica tu red." | +| Server error (500) | "Nuestros servidores tienen problemas. Inténtalo de nuevo." | +| Not found (404) | "No se encontró el recurso solicitado." | +| Unauthorized (401) | "Tu sesión ha expirado. Inicia sesión nuevamente." | +| Validation error | Error específico del campo | + +--- + +### ✅ **Success Criteria** + +The centralized error handling is working correctly if: + +1. **No Crashes**: App never crashes due to network/server errors +2. **Consistent Display**: All errors appear in `UiSnackbar` with same styling +3. **User-Friendly**: Messages are clear, specific, and actionable +4. **Localized**: All errors translate correctly to Spanish +5. **Graceful Degradation**: App remains usable even when errors occur +6. **Data Preservation**: Form data is not lost when errors happen + +--- + +### 🐛 **What to Report if Tests Fail** + +If any test fails, please report: +1. **Which test** (e.g., "Test A2: Network Error During Submission") +2. **What happened** (e.g., "App crashed" or "Error showed in English despite Spanish language") +3. **Screenshot** of the error (if visible) +4. **Steps to reproduce** + +--- + +### 🔧 **Quick Debug Commands** + +For developers debugging error handling: + +```dart +// Test error translation directly +print(translateErrorKey('errors.network.no_connection')); +print(translateErrorKey('errors.server.internal')); +print(translateErrorKey('errors.not_found.shift')); + +// Test in Spanish +LocaleSettings.setLocale(AppLocale.es); +print(translateErrorKey('errors.network.no_connection')); +``` + +--- + +## 6. Code Locations (Reference) * **Exceptions:** `packages/domain/lib/src/exceptions/app_exception.dart` * **Data Mixin:** `packages/data_connect/lib/src/mixins/data_error_handler.dart` * **Bloc Mixin:** `packages/core/lib/src/presentation/mixins/bloc_error_handler.dart`