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 9eaf5902..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": { @@ -680,7 +917,8 @@ "title": "Shifts", "tabs": { "my_shifts": "My Shifts", - "find_work": "Find Work" + "find_work": "Find Shifts", + "history": "History" }, "list": { "no_shifts": "No shifts found", @@ -716,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": { @@ -735,7 +1007,7 @@ }, "errors": { "auth": { - "invalid_credentials": "The email or password you entered is incorrect.", + "invalid_credentials": "Invalid verification code or password. Please try again.", "account_exists": "An account with this email already exists. Try signing in instead.", "session_expired": "Your session has expired. Please sign in again.", "user_not_found": "We couldn't find your account. Please check your email and try again.", @@ -744,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." }, @@ -787,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 4f80e30a..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" + } } } } @@ -680,7 +917,8 @@ "title": "Turnos", "tabs": { "my_shifts": "Mis Turnos", - "find_work": "Buscar Trabajo" + "find_work": "Buscar Trabajo", + "history": "Historial" }, "list": { "no_shifts": "No se encontraron turnos", @@ -716,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": { @@ -744,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." }, @@ -786,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/data_connect/lib/src/mixins/data_error_handler.dart b/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart index d84b24e1..27ca4624 100644 --- a/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart +++ b/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart @@ -24,11 +24,17 @@ mixin DataErrorHandler { } on SocketException catch (e) { throw NetworkException(technicalMessage: 'SocketException: ${e.message}'); } on FirebaseException catch (e) { - if (e.code == 'unavailable' || e.code == 'network-request-failed') { + final String code = e.code.toLowerCase(); + final String msg = (e.message ?? '').toLowerCase(); + if (code == 'unavailable' || + code == 'network-request-failed' || + msg.contains('offline') || + msg.contains('network') || + msg.contains('connection failed')) { throw NetworkException( technicalMessage: 'Firebase ${e.code}: ${e.message}'); } - if (e.code == 'deadline-exceeded') { + if (code == 'deadline-exceeded') { throw ServiceUnavailableException( technicalMessage: 'Firebase ${e.code}: ${e.message}'); } @@ -36,8 +42,28 @@ mixin DataErrorHandler { throw ServerException( technicalMessage: 'Firebase ${e.code}: ${e.message}'); } catch (e) { + final String errorStr = e.toString().toLowerCase(); + if (errorStr.contains('socketexception') || + errorStr.contains('network') || + errorStr.contains('offline') || + errorStr.contains('connection failed') || + errorStr.contains('unavailable') || + errorStr.contains('handshake') || + errorStr.contains('clientexception') || + errorStr.contains('failed host lookup') || + errorStr.contains('connection error') || + errorStr.contains('grpc error') || + errorStr.contains('terminated') || + errorStr.contains('connectexception')) { + throw NetworkException(technicalMessage: e.toString()); + } + // If it's already an AppException, rethrow it if (e is AppException) rethrow; + + // Debugging: Log unexpected errors + print('DataErrorHandler: Unhandled exception caught: $e'); + throw UnknownException(technicalMessage: e.toString()); } } 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 ee690605..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,10 +193,28 @@ class _BillingViewState extends State { } if (state.status == BillingStatus.failure) { - return Center( - child: Text( - 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/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index e7aa6a97..36f3ffe5 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -60,9 +60,17 @@ class AuthRepositoryImpl }, verificationFailed: (FirebaseAuthException e) { if (!completer.isCompleted) { - completer.completeError( - Exception(e.message ?? 'Phone verification failed.'), - ); + // Map Firebase network errors to NetworkException + if (e.code == 'network-request-failed' || + e.message?.contains('Unable to resolve host') == true) { + completer.completeError( + const domain.NetworkException(technicalMessage: 'Auth network failure'), + ); + } else { + completer.completeError( + domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'), + ); + } } }, codeSent: (String verificationId, _) { @@ -107,10 +115,25 @@ class AuthRepositoryImpl verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await firebaseAuth.signInWithCredential(credential); + final UserCredential userCredential = await executeProtected( + () async { + try { + return await firebaseAuth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const domain.InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); + } + rethrow; + } + }, + ); final User? firebaseUser = userCredential.user; if (firebaseUser == null) { - throw Exception('Phone verification failed, no Firebase user received.'); + throw const domain.SignInFailedException( + technicalMessage: 'Phone verification failed, no Firebase user received.', + ); } final QueryResult response = @@ -135,7 +158,9 @@ class AuthRepositoryImpl } else { if (user.userRole != 'STAFF') { await firebaseAuth.signOut(); - throw Exception('User is not authorized for this app.'); + throw const domain.UnauthorizedAppException( + technicalMessage: 'User is not authorized for this app.', + ); } final QueryResult staffResponse = await executeProtected(() => dataConnect @@ -145,19 +170,23 @@ class AuthRepositoryImpl .execute()); if (staffResponse.data.staffs.isNotEmpty) { await firebaseAuth.signOut(); - throw Exception( - 'This user already has a staff profile. Please log in.', + throw const domain.AccountExistsException( + technicalMessage: 'This user already has a staff profile. Please log in.', ); } } } else { if (user == null) { await firebaseAuth.signOut(); - throw Exception('Authenticated user profile not found in database.'); + throw const domain.UserNotFoundException( + technicalMessage: 'Authenticated user profile not found in database.', + ); } if (user.userRole != 'STAFF') { await firebaseAuth.signOut(); - throw Exception('User is not authorized for this app.'); + throw const domain.UnauthorizedAppException( + technicalMessage: 'User is not authorized for this app.', + ); } final QueryResult @@ -168,8 +197,8 @@ class AuthRepositoryImpl .execute()); if (staffResponse.data.staffs.isEmpty) { await firebaseAuth.signOut(); - throw Exception( - 'Your account is not registered yet. Please register first.', + throw const domain.UserNotFoundException( + technicalMessage: 'Your account is not registered yet. Please register first.', ); } staffRecord = staffResponse.data.staffs.first; 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 8d8a9db1..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 @@ -39,7 +39,9 @@ class _PhoneVerificationPageState extends State { @override void dispose() { - _authBloc.add(AuthResetRequested(mode: widget.mode)); + if (!_authBloc.isClosed) { + _authBloc.add(AuthResetRequested(mode: widget.mode)); + } super.dispose(); } @@ -50,16 +52,17 @@ class _PhoneVerificationPageState extends State { }) { final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), ''); if (normalized.length == 10) { - BlocProvider.of(context).add( + BlocProvider.of( + context, + ).add( 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), ); } } @@ -70,7 +73,9 @@ class _PhoneVerificationPageState extends State { required String otp, required String verificationId, }) { - BlocProvider.of(context).add( + BlocProvider.of( + context, + ).add( AuthOtpSubmitted( verificationId: verificationId, smsCode: otp, @@ -81,9 +86,9 @@ class _PhoneVerificationPageState extends State { /// Handles the request to resend the verification code using the phone number in the state. void _onResend({required BuildContext context}) { - BlocProvider.of( - context, - ).add(AuthSignInRequested(mode: widget.mode)); + BlocProvider.of(context).add( + AuthSignInRequested(mode: widget.mode), + ); } @override @@ -102,23 +107,20 @@ class _PhoneVerificationPageState extends State { } } else if (state.status == AuthStatus.error && state.mode == AuthMode.signup) { - final String message = state.errorMessage ?? ''; - if (message.contains('staff profile')) { - final ScaffoldMessengerState messenger = ScaffoldMessenger.of( + final String messageKey = state.errorMessage ?? ''; + // Handle specific business logic errors for signup + if (messageKey == 'errors.auth.account_exists') { + UiSnackbar.show( context, - ); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 5), - ), + 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; Modular.to.navigate('/'); }); - } else if (message.contains('not authorized')) { + } else if (messageKey == 'errors.auth.unauthorized_app') { Modular.to.pop(); } } @@ -145,9 +147,9 @@ class _PhoneVerificationPageState extends State { centerTitle: true, showBackButton: true, onLeadingPressed: () { - BlocProvider.of( - context, - ).add(AuthResetRequested(mode: widget.mode)); + BlocProvider.of(context).add( + AuthResetRequested(mode: widget.mode), + ); Navigator.of(context).pop(); }, ), @@ -167,13 +169,13 @@ class _PhoneVerificationPageState extends State { verificationId: state.verificationId ?? '', ), ) - : PhoneInput( - state: state, - onSendCode: (String phoneNumber) => _onSendCode( - context: context, - phoneNumber: phoneNumber, - ), + : PhoneInput( + state: state, + onSendCode: (String phoneNumber) => _onSendCode( + context: context, + phoneNumber: phoneNumber, ), + ), ), ), ); 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/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart index 1c36e3a8..92c16795 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/get_started_page/get_started_background.dart @@ -1,71 +1,119 @@ -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; -class GetStartedBackground extends StatelessWidget { +class GetStartedBackground extends StatefulWidget { const GetStartedBackground({super.key}); + @override + State createState() => _GetStartedBackgroundState(); +} + +class _GetStartedBackgroundState extends State { + bool _hasError = false; + @override Widget build(BuildContext context) { - return Column( - children: [ - const SizedBox(height: UiConstants.space8), - // Logo - Image.asset(UiImageAssets.logoBlue, height: 40), - Expanded( - child: Center( - child: Container( - width: 288, - height: 288, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: UiColors.bgSecondary.withValues(alpha: 0.5), - ), - padding: const EdgeInsets.all(UiConstants.space2), - child: ClipOval( - child: Image.network( - 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Image.asset(UiImageAssets.logoBlue); - }, + return Container( + child: Column( + children: [ + const SizedBox(height: 32), + // Logo + Image.asset( + UiImageAssets.logoBlue, + height: 40, + ), + Expanded( + child: Center( + child: Container( + width: 288, + height: 288, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF3A4A5A).withOpacity(0.05), + ), + padding: const EdgeInsets.all(8.0), + child: ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + // Layer 1: The Fallback Logo (Always visible until image loads) + Padding( + padding: const EdgeInsets.all(48.0), + child: Image.asset(UiImageAssets.logoBlue), + ), + + // Layer 2: The Network Image (Only visible on success) + if (!_hasError) + Image.network( + 'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) return child; + // Only animate opacity if we have a frame + return AnimatedOpacity( + opacity: frame == null ? 0 : 1, + duration: const Duration(milliseconds: 300), + child: child, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + // While loading, show nothing (transparent) so layer 1 shows + if (loadingProgress == null) return child; + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + // On error, show nothing (transparent) so layer 1 shows + // Also schedule a state update to prevent retries if needed + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_hasError) { + setState(() { + _hasError = true; + }); + } + }); + return const SizedBox.shrink(); + }, + ), + ], + ), ), ), ), ), - ), - // Pagination dots (Visual only) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 24, - height: 8, - decoration: BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.circular(4), + // Pagination dots (Visual only) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 24, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.circular(4), + ), ), - ), - const SizedBox(width: 8), - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), ), - ), - const SizedBox(width: 8), - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: UiColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart index 2eda5bd1..ca756ad0 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart @@ -1,6 +1,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../blocs/auth_event.dart'; import '../../../blocs/auth_bloc.dart'; @@ -118,7 +119,10 @@ class _OtpInputFieldState extends State { Padding( padding: const EdgeInsets.only(top: UiConstants.space4), child: Center( - child: Text(widget.error, style: UiTypography.body2r.textError), + child: Text( + translateErrorKey(widget.error), + style: UiTypography.body2r.textError, + ), ), ), ], diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart index 9065f986..e12cd2da 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart @@ -1,7 +1,8 @@ -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:staff_authentication/staff_authentication.dart'; /// A widget that displays the phone number input field with country code. /// @@ -100,7 +101,10 @@ class _PhoneInputFormFieldState extends State { if (widget.error.isNotEmpty) Padding( padding: const EdgeInsets.only(top: UiConstants.space2), - child: Text(widget.error, style: UiTypography.body2r.textError), + child: Text( + translateErrorKey(widget.error), + style: UiTypography.body2r.textError, + ), ), ], ); 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 0de2fce2..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 @@ -9,9 +9,12 @@ import '../../domain/repositories/availability_repository.dart'; /// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek), /// not specific date availability. Therefore, updating availability for a specific /// date will update the availability for that Day of Week globally (Recurring). -class AvailabilityRepositoryImpl implements AvailabilityRepository { +class AvailabilityRepositoryImpl + with dc.DataErrorHandler + implements AvailabilityRepository { final dc.ExampleConnector _dataConnect; final firebase.FirebaseAuth _firebaseAuth; + String? _cachedStaffId; AvailabilityRepositoryImpl({ required dc.ExampleConnector dataConnect, @@ -20,85 +23,93 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { _firebaseAuth = firebaseAuth; Future _getStaffId() async { + if (_cachedStaffId != null) return _cachedStaffId!; + final firebase.User? user = _firebaseAuth.currentUser; - if (user == null) throw Exception('User not authenticated'); + if (user == null) { + throw NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } final QueryResult result = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); if (result.data.staffs.isEmpty) { - throw Exception('Staff profile not found'); + throw const ServerException(technicalMessage: 'Staff profile not found'); } - return result.data.staffs.first.id; + _cachedStaffId = result.data.staffs.first.id; + return _cachedStaffId!; } @override Future> getAvailability(DateTime start, DateTime end) async { - final String staffId = await _getStaffId(); - - // 1. Fetch Weekly recurring availability - final QueryResult result = - await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); - - final List items = result.data.staffAvailabilities; - - // 2. Map to lookup: DayOfWeek -> Map - final Map> weeklyMap = {}; - - for (final item in items) { - dc.DayOfWeek day; - try { - day = dc.DayOfWeek.values.byName(item.day.stringValue); - } catch (_) { - continue; - } - - dc.AvailabilitySlot slot; - try { - slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue); - } catch (_) { - continue; - } - - bool isAvailable = false; - try { - final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue); - isAvailable = _statusToBool(status); - } catch (_) { - isAvailable = false; - } - - if (!weeklyMap.containsKey(day)) { - weeklyMap[day] = {}; - } - weeklyMap[day]![slot] = isAvailable; - } - - // 3. Generate DayAvailability for requested range - final List days = []; - final int dayCount = end.difference(start).inDays; - - for (int i = 0; i <= dayCount; i++) { - final DateTime date = start.add(Duration(days: i)); - final dc.DayOfWeek dow = _toBackendDay(date.weekday); - - final Map daySlots = weeklyMap[dow] ?? {}; + return executeProtected(() async { + final String staffId = await _getStaffId(); - // We define 3 standard slots for every day - final List slots = [ - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING), - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON), - _createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING), - ]; + // 1. Fetch Weekly recurring availability + final QueryResult result = + await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); - final bool isDayAvailable = slots.any((s) => s.isAvailable); + final List items = result.data.staffAvailabilities; - days.add(DayAvailability( - date: date, - isAvailable: isDayAvailable, - slots: slots, - )); - } - return days; + // 2. Map to lookup: DayOfWeek -> Map + final Map> weeklyMap = {}; + + for (final item in items) { + dc.DayOfWeek day; + try { + day = dc.DayOfWeek.values.byName(item.day.stringValue); + } catch (_) { + continue; + } + + dc.AvailabilitySlot slot; + try { + slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue); + } catch (_) { + continue; + } + + bool isAvailable = false; + try { + final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue); + isAvailable = _statusToBool(status); + } catch (_) { + isAvailable = false; + } + + if (!weeklyMap.containsKey(day)) { + weeklyMap[day] = {}; + } + weeklyMap[day]![slot] = isAvailable; + } + + // 3. Generate DayAvailability for requested range + final List days = []; + final int dayCount = end.difference(start).inDays; + + for (int i = 0; i <= dayCount; i++) { + final DateTime date = start.add(Duration(days: i)); + final dc.DayOfWeek dow = _toBackendDay(date.weekday); + + final Map daySlots = weeklyMap[dow] ?? {}; + + // We define 3 standard slots for every day + final List slots = [ + _createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING), + _createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON), + _createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING), + ]; + + final bool isDayAvailable = slots.any((s) => s.isAvailable); + + days.add(DayAvailability( + date: date, + isAvailable: isDayAvailable, + slots: slots, + )); + } + return days; + }); } AvailabilitySlot _createSlot( @@ -113,65 +124,68 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { @override Future updateDayAvailability(DayAvailability availability) async { - final String staffId = await _getStaffId(); - final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday); + return executeProtected(() async { + final String staffId = await _getStaffId(); + final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday); - // Update each slot in the backend. - // This updates the recurring rule for this DayOfWeek. - for (final AvailabilitySlot slot in availability.slots) { - final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id); - final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable); + // Update each slot in the backend. + // This updates the recurring rule for this DayOfWeek. + for (final AvailabilitySlot slot in availability.slots) { + final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id); + final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable); - await _upsertSlot(staffId, dow, slotEnum, status); - } + await _upsertSlot(staffId, dow, slotEnum, status); + } - return availability; + return availability; + }); } @override Future> applyQuickSet(DateTime start, DateTime end, String type) async { - final String staffId = await _getStaffId(); - - // QuickSet updates the Recurring schedule for all days involved. - // However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri. - - final int dayCount = end.difference(start).inDays; - final Set processedDays = {}; - final List resultDays = []; + return executeProtected(() async { + final String staffId = await _getStaffId(); + + // QuickSet updates the Recurring schedule for all days involved. + // However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri. + + final int dayCount = end.difference(start).inDays; + final Set processedDays = {}; + final List resultDays = []; - for (int i = 0; i <= dayCount; i++) { + 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); + 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); + enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY); } // 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), - ]); + 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), + AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay), + AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay), + AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay), ]; resultDays.add(DayAvailability( @@ -179,9 +193,13 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository { isAvailable: enableDay, slots: slots, )); - } + } - return resultDays; + // Execute all updates in parallel + await Future.wait(futures); + + return resultDays; + }); } Future _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async { 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 53e84cbc..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 @@ -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'; @@ -41,11 +42,12 @@ class _AvailabilityPageState extends State { @override Widget build(BuildContext 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, ), @@ -62,7 +64,7 @@ class _AvailabilityPageState extends State { if (state is AvailabilityError) { UiSnackbar.show( context, - message: state.message, + message: translateErrorKey(state.message), type: UiSnackbarType.error, ); } @@ -100,14 +102,28 @@ 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) { return Center( - child: Text( - 'Error: ${state.message}', - style: UiTypography.body2r.textError, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey(state.message), + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + ], + ), ), ); } @@ -120,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( @@ -130,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, ), @@ -368,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, ), ], @@ -540,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( @@ -557,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 0164b396..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 @@ -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'; @@ -31,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( @@ -39,7 +41,7 @@ class _ClockInPageState extends State { state.errorMessage != null) { UiSnackbar.show( context, - message: state.errorMessage!, + message: translateErrorKey(state.errorMessage!), type: UiSnackbarType.error, ); } @@ -67,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, @@ -114,7 +116,7 @@ class _ClockInPageState extends State { // Your Activity Header Text( - "Your Activity", + i18n.your_activity, textAlign: TextAlign.start, style: UiTypography.headline4m, ), @@ -160,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, @@ -236,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, ), @@ -315,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, ), ], @@ -338,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, ), @@ -376,7 +384,7 @@ class _ClockInPageState extends State { CrossAxisAlignment.start, children: [ Text( - "Checked in at", + i18n.checked_in_at_label, style: UiTypography.body3m.textSuccess, ), Text( @@ -471,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 @@ -481,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: [ @@ -502,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, ), @@ -537,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( @@ -607,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/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 67594247..7de014d6 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -32,6 +32,7 @@ class WorkerHomePage extends StatelessWidget { @override Widget build(BuildContext context) { + final t = Translations.of(context); final i18n = t.staff.home; final bannersI18n = i18n.banners; final quickI18n = i18n.quick_actions; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index 25cdd03f..a0791ab5 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -6,7 +6,9 @@ import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:krow_core/core.dart'; import '../../domain/repositories/payments_repository.dart'; -class PaymentsRepositoryImpl implements PaymentsRepository { +class PaymentsRepositoryImpl + with dc.DataErrorHandler + implements PaymentsRepository { PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; final dc.ExampleConnector _dataConnect; @@ -27,17 +29,18 @@ class PaymentsRepositoryImpl implements PaymentsRepository { // 3. Fetch from Data Connect using Firebase UID final firebase_auth.User? user = _auth.currentUser; if (user == null) { - throw Exception('User is not authenticated'); + throw const NotAuthenticatedException( + technicalMessage: 'User is not authenticated', + ); } - try { - final QueryResult response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (response.data.staffs.isNotEmpty) { - _cachedStaffId = response.data.staffs.first.id; - return _cachedStaffId!; - } - } catch (e) { - // Log or handle error + // This call is protected by parent execution context if called within executeProtected, + // otherwise we might need to wrap it if called standalone. + // For now we assume it's called from public methods which are protected. + final QueryResult response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); + if (response.data.staffs.isNotEmpty) { + _cachedStaffId = response.data.staffs.first.id; + return _cachedStaffId!; } // 4. Fallback @@ -78,55 +81,57 @@ class PaymentsRepositoryImpl implements PaymentsRepository { @override Future getPaymentSummary() async { - final String currentStaffId = await _getStaffId(); + return executeProtected(() async { + final String currentStaffId = await _getStaffId(); - // Fetch recent payments with a limit - // Note: limit is chained on the query builder - final QueryResult result = - await _dataConnect.listRecentPaymentsByStaffId( - staffId: currentStaffId, - ).limit(100).execute(); + // Fetch recent payments with a limit + // Note: limit is chained on the query builder + final QueryResult result = + await _dataConnect.listRecentPaymentsByStaffId( + staffId: currentStaffId, + ).limit(100).execute(); - final List payments = result.data.recentPayments; + final List payments = result.data.recentPayments; - double weekly = 0; - double monthly = 0; - double pending = 0; - double total = 0; + double weekly = 0; + double monthly = 0; + double pending = 0; + double total = 0; - final DateTime now = DateTime.now(); - final DateTime startOfWeek = now.subtract(const Duration(days: 7)); - final DateTime startOfMonth = DateTime(now.year, now.month, 1); + final DateTime now = DateTime.now(); + final DateTime startOfWeek = now.subtract(const Duration(days: 7)); + final DateTime startOfMonth = DateTime(now.year, now.month, 1); - for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) { - final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt); - final double amount = p.invoice.amount; - final String? status = p.status?.stringValue; + for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) { + final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt); + final double amount = p.invoice.amount; + final String? status = p.status?.stringValue; - if (status == 'PENDING') { - pending += amount; - } else if (status == 'PAID') { - total += amount; - if (date != null) { - if (date.isAfter(startOfWeek)) weekly += amount; - if (date.isAfter(startOfMonth)) monthly += amount; - } - } - } + if (status == 'PENDING') { + pending += amount; + } else if (status == 'PAID') { + total += amount; + if (date != null) { + if (date.isAfter(startOfWeek)) weekly += amount; + if (date.isAfter(startOfMonth)) monthly += amount; + } + } + } - return PaymentSummary( - weeklyEarnings: weekly, - monthlyEarnings: monthly, - pendingEarnings: pending, - totalEarnings: total, - ); + return PaymentSummary( + weeklyEarnings: weekly, + monthlyEarnings: monthly, + pendingEarnings: pending, + totalEarnings: total, + ); + }); } @override Future> getPaymentHistory(String period) async { - final String currentStaffId = await _getStaffId(); - - try { + return executeProtected(() async { + final String currentStaffId = await _getStaffId(); + final QueryResult response = await _dataConnect .listRecentPaymentsByStaffId(staffId: currentStaffId) @@ -142,9 +147,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository { paidAt: _toDateTime(payment.invoice.issueDate), ); }).toList(); - } catch (e) { - return []; - } + }); } } 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 56ed57b0..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 @@ -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_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; import '../blocs/payments/payments_bloc.dart'; import '../blocs/payments/payments_event.dart'; import '../blocs/payments/payments_state.dart'; @@ -30,23 +31,28 @@ class _PaymentsPageState extends State { @override Widget build(BuildContext context) { + Translations.of(context); return BlocProvider.value( value: _bloc, child: Scaffold( backgroundColor: UiColors.background, - body: BlocBuilder( + body: BlocConsumer( + listener: (context, state) { + // Error is already shown on the page itself (lines 53-63), no need for snackbar + }, builder: (BuildContext context, PaymentsState state) { if (state is PaymentsLoading) { - return Center( - child: CircularProgressIndicator( - color: UiColors.primary, - ), - ); + return const Center(child: CircularProgressIndicator()); + } else if (state is PaymentsError) { return Center( - child: Text( - 'Error: ${state.message}', - style: UiTypography.body2r.textError, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + translateErrorKey(state.message), + textAlign: TextAlign.center, + style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), + ), ), ); } else if (state is PaymentsLoaded) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index c6d2e792..32bbcec5 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -14,7 +14,9 @@ import '../../domain/repositories/profile_repository.dart'; /// /// Currently uses [ProfileRepositoryMock] from data_connect. /// When Firebase Data Connect is ready, this will be swapped with a real implementation. -class ProfileRepositoryImpl implements ProfileRepositoryInterface { +class ProfileRepositoryImpl + with DataErrorHandler + implements ProfileRepositoryInterface { /// Creates a [ProfileRepositoryImpl]. /// /// Requires a [ExampleConnector] from the data_connect package and [FirebaseAuth]. @@ -31,37 +33,39 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface { @override Future getStaffProfile() async { - final user = firebaseAuth.currentUser; - if (user == null) { - throw Exception('User not authenticated'); - } + return executeProtected(() async { + final user = firebaseAuth.currentUser; + if (user == null) { + throw NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } - final response = await connector.getStaffByUserId(userId: user.uid).execute(); - - if (response.data.staffs.isEmpty) { - // TODO: Handle user not found properly with domain exception - throw Exception('Staff not found'); - } + final response = await connector.getStaffByUserId(userId: user.uid).execute(); + + if (response.data.staffs.isEmpty) { + throw const ServerException(technicalMessage: 'Staff not found'); + } - final GetStaffByUserIdStaffs rawStaff = response.data.staffs.first; + final GetStaffByUserIdStaffs rawStaff = response.data.staffs.first; - // Map the raw data connect object to the Domain Entity - return Staff( - id: rawStaff.id, - authProviderId: rawStaff.userId, - name: rawStaff.fullName, - email: rawStaff.email ?? '', - phone: rawStaff.phone, - avatar: rawStaff.photoUrl, - status: StaffStatus.active, - address: rawStaff.addres, - totalShifts: rawStaff.totalShifts, - averageRating: rawStaff.averageRating, - onTimeRate: rawStaff.onTimeRate, - noShowCount: rawStaff.noShowCount, - cancellationCount: rawStaff.cancellationCount, - reliabilityScore: rawStaff.reliabilityScore, - ); + // Map the raw data connect object to the Domain Entity + return Staff( + id: rawStaff.id, + authProviderId: rawStaff.userId, + name: rawStaff.fullName, + email: rawStaff.email ?? '', + phone: rawStaff.phone, + avatar: rawStaff.photoUrl, + status: StaffStatus.active, + address: rawStaff.addres, + totalShifts: rawStaff.totalShifts, + averageRating: rawStaff.averageRating, + onTimeRate: rawStaff.onTimeRate, + noShowCount: rawStaff.noShowCount, + cancellationCount: rawStaff.cancellationCount, + reliabilityScore: rawStaff.reliabilityScore, + ); + }); } @override 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 2bd2f638..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 @@ -1,21 +1,22 @@ -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' hide ReadContext; 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 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; +import 'package:krow_core/core.dart'; import '../widgets/logout_button.dart'; -import '../widgets/profile_header.dart'; import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_item.dart'; +import '../widgets/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; +import '../widgets/reliability_stats_card.dart'; import '../widgets/section_title.dart'; +import '../widgets/language_selector_bottom_sheet.dart'; /// The main Staff Profile page. /// @@ -49,7 +50,7 @@ class StaffProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsStaffProfileEn i18n = t.staff.profile; + final i18n = Translations.of(context).staff.profile; final ProfileCubit cubit = Modular.get(); // Load profile data on first build @@ -63,19 +64,34 @@ class StaffProfilePage extends StatelessWidget { listener: (context, state) { if (state.status == ProfileStatus.signedOut) { Modular.to.toGetStarted(); + } else if (state.status == ProfileStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); } }, builder: (context, state) { // Show loading spinner if status is loading - if (state.status == ProfileStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } + if (state.status == ProfileStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } - if (state.status == ProfileStatus.error) { + if (state.status == ProfileStatus.error) { return Center( - child: Text( - state.errorMessage ?? 'An error occurred', - style: UiTypography.body1r.textError, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + textAlign: TextAlign.center, + style: UiTypography.body1r.copyWith( + color: UiColors.textSecondary, + ), + ), ), ); } @@ -118,6 +134,7 @@ class StaffProfilePage extends StatelessWidget { SectionTitle(i18n.sections.onboarding), ProfileMenuGrid( crossAxisCount: 3, + children: [ ProfileMenuItem( icon: UiIcons.user, @@ -177,23 +194,18 @@ class StaffProfilePage extends StatelessWidget { ), const SizedBox(height: UiConstants.space6), SectionTitle( - i18n.header.title.contains("Perfil") - ? "Ajustes" - : "Settings", + i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings", ), ProfileMenuGrid( crossAxisCount: 3, children: [ ProfileMenuItem( icon: UiIcons.globe, - label: i18n.header.title.contains("Perfil") - ? "Idioma" - : "Language", + label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language", onTap: () { showModalBottomSheet( context: context, - builder: (context) => - const LanguageSelectorBottomSheet(), + builder: (context) => const LanguageSelectorBottomSheet(), ); }, ), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart index 06c4bda9..d703b41b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/language_selector_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; /// A bottom sheet that allows the user to select their preferred language. @@ -14,36 +15,36 @@ class LanguageSelectorBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: const BoxDecoration( + padding: EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( color: UiColors.background, - borderRadius: BorderRadius.vertical( - top: Radius.circular(UiConstants.radiusBase), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.radiusBase)), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.settings.change_language, - style: UiTypography.headline4m, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space6), - _buildLanguageOption( - context, - label: 'English', - locale: AppLocale.en, - ), - const SizedBox(height: UiConstants.space4), - _buildLanguageOption( - context, - label: 'Español', - locale: AppLocale.es, - ), - const SizedBox(height: UiConstants.space6), - ], + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.settings.change_language, + style: UiTypography.headline4m, + textAlign: TextAlign.center, + ), + SizedBox(height: UiConstants.space6), + _buildLanguageOption( + context, + label: 'English', + locale: AppLocale.en, + ), + SizedBox(height: UiConstants.space4), + _buildLanguageOption( + context, + label: 'Español', + locale: AppLocale.es, + ), + SizedBox(height: UiConstants.space6), + ], + ), ), ); } @@ -63,23 +64,21 @@ class LanguageSelectorBottomSheet extends StatelessWidget { onTap: () { // Dispatch the ChangeLocale event to the LocaleBloc Modular.get().add(ChangeLocale(locale.flutterLocale)); - + // Close the bottom sheet Navigator.pop(context); - + // Force a rebuild of the entire app to reflect locale change instantly if not handled by root widget // (Usually handled by BlocBuilder at the root, but this ensures settings are updated) }, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), child: Container( - padding: const EdgeInsets.symmetric( + padding: EdgeInsets.symmetric( vertical: UiConstants.space4, horizontal: UiConstants.space4, ), decoration: BoxDecoration( - color: isSelected - ? UiColors.primary.withValues(alpha: 0.1) - : UiColors.background, + color: isSelected ? UiColors.primary.withValues(alpha: 0.1) : UiColors.background, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), border: Border.all( color: isSelected ? UiColors.primary : UiColors.border, @@ -91,10 +90,12 @@ class LanguageSelectorBottomSheet extends StatelessWidget { children: [ Text( label, - style: isSelected ? UiTypography.body1b.primary : UiTypography.body1r, + style: isSelected + ? UiTypography.body1b.copyWith(color: UiColors.primary) + : UiTypography.body1r, ), if (isSelected) - const Icon( + Icon( UiIcons.check, color: UiColors.primary, size: 24.0, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 90c40c0e..411ce9b5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -10,7 +10,9 @@ import '../../domain/repositories/certificates_repository.dart'; /// /// This class handles the communication with the backend via [ExampleConnector]. /// It maps raw generated data types to clean [domain.StaffDocument] entities. -class CertificatesRepositoryImpl implements CertificatesRepository { +class CertificatesRepositoryImpl + with DataErrorHandler + implements CertificatesRepository { /// The generated Data Connect SDK client. final ExampleConnector _dataConnect; @@ -24,16 +26,17 @@ class CertificatesRepositoryImpl implements CertificatesRepository { required ExampleConnector dataConnect, required FirebaseAuth firebaseAuth, }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; + _firebaseAuth = firebaseAuth; @override Future> getCertificates() async { - final User? currentUser = _firebaseAuth.currentUser; - if (currentUser == null) { - throw Exception('User not authenticated'); - } + return executeProtected(() async { + final User? currentUser = _firebaseAuth.currentUser; + if (currentUser == null) { + throw domain.NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } - try { // Execute the query via DataConnect generated SDK final QueryResult result = @@ -46,10 +49,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository { .map((ListStaffDocumentsByStaffIdStaffDocuments doc) => _mapToDomain(doc)) .toList(); - } catch (e) { - // In a real app, we would map specific exceptions to domain Failures here. - throw Exception('Failed to fetch certificates: $e'); - } + }); } /// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument]. 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 cd28047d..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 @@ -35,7 +35,19 @@ class CertificatesPage extends StatelessWidget { if (state.status == CertificatesStatus.failure) { return Scaffold( - body: Center(child: Text('Error: ${state.errorMessage}')), + appBar: AppBar(title: const Text('Certificates')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'Error loading certificates', + textAlign: TextAlign.center, + style: const TextStyle(color: UiColors.textSecondary), + ), + ), + ), ); } @@ -62,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/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index 08d6014b..2a82c255 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -7,7 +7,9 @@ import 'package:krow_core/core.dart'; import '../../domain/repositories/documents_repository.dart'; /// Implementation of [DocumentsRepository] using Data Connect. -class DocumentsRepositoryImpl implements DocumentsRepository { +class DocumentsRepositoryImpl + with DataErrorHandler + implements DocumentsRepository { final ExampleConnector _dataConnect; final FirebaseAuth _firebaseAuth; @@ -19,10 +21,12 @@ class DocumentsRepositoryImpl implements DocumentsRepository { @override Future> getDocuments() async { - final User? currentUser = _firebaseAuth.currentUser; - if (currentUser == null) { - throw Exception('User not authenticated'); - } + return executeProtected(() async { + final User? currentUser = _firebaseAuth.currentUser; + if (currentUser == null) { + throw domain.NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } /// MOCK IMPLEMENTATION /// To be replaced with real data connect query when available @@ -49,22 +53,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository { ), ]; - /* - try { - final QueryResult result = - await _dataConnect - .listStaffDocumentsByStaffId(staffId: currentUser.uid) - .execute(); - - return result.data.staffDocuments - .map((ListStaffDocumentsByStaffIdStaffDocuments doc) => - _mapToDomain(doc)) - .toList(); - } catch (e) { - throw Exception('Failed to fetch documents: $e'); - } - */ + }); } domain.StaffDocument _mapToDomain( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 28f95fcf..af62abf7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -25,14 +25,15 @@ class DocumentsPage extends StatelessWidget { return Scaffold( appBar: AppBar( elevation: 0, - backgroundColor: UiColors.bgPopup, leading: IconButton( icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary), onPressed: () => Modular.to.pop(), ), title: Text( t.staff_documents.title, - style: UiTypography.headline3m.textPrimary, + style: UiTypography.headline3m.copyWith( + color: UiColors.textPrimary, + ), ), bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), @@ -51,11 +52,17 @@ class DocumentsPage extends StatelessWidget { } if (state.status == DocumentsStatus.failure) { return Center( - child: Text( - t.staff_documents.list.error( - message: state.errorMessage ?? 'Unknown', + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + state.errorMessage != null + ? (state.errorMessage!.contains('errors.') + ? translateErrorKey(state.errorMessage!) + : t.staff_documents.list.error(message: state.errorMessage!)) + : t.staff_documents.list.error(message: 'Unknown'), + textAlign: TextAlign.center, + style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), ), - style: UiTypography.body1m.textError, ), ); } @@ -63,23 +70,20 @@ class DocumentsPage extends StatelessWidget { return Center( child: Text( t.staff_documents.list.empty, - style: UiTypography.body1m.textSecondary, + style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), ), ); } return ListView( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - vertical: UiConstants.space6, - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), children: [ DocumentsProgressCard( completedCount: state.completedCount, totalCount: state.totalCount, progress: state.progress, ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: 16), ...state.documents.map( (StaffDocument doc) => DocumentCard( document: doc, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart index 7307c194..be9a1cc0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as auth; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; @@ -8,7 +9,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/tax_forms_repository.dart'; import '../mappers/tax_form_mapper.dart'; -class TaxFormsRepositoryImpl implements TaxFormsRepository { +class TaxFormsRepositoryImpl + with dc.DataErrorHandler + implements TaxFormsRepository { TaxFormsRepositoryImpl({ required this.firebaseAuth, required this.dataConnect, @@ -21,46 +24,58 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { String _getStaffId() { final auth.User? user = firebaseAuth.currentUser; if (user == null) { - throw Exception('User not authenticated'); + throw const NotAuthenticatedException( + technicalMessage: 'Firebase User is null', + ); } - + final String? staffId = dc.StaffSessionStore.instance.session?.staff?.id; if (staffId == null || staffId.isEmpty) { - throw Exception('Staff profile is missing or session not initialized.'); + throw const StaffProfileNotFoundException( + technicalMessage: 'Staff ID missing in SessionStore', + ); } return staffId; } @override Future> getTaxForms() async { - final String staffId = _getStaffId(); - final QueryResult - result = - await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute(); - - final List forms = result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); - - // Check if required forms exist, create if not. - final Set typesPresent = forms.map((TaxForm f) => f.type).toSet(); - bool createdNew = false; - - if (!typesPresent.contains(TaxFormType.i9)) { - await _createInitialForm(staffId, TaxFormType.i9); - createdNew = true; - } - if (!typesPresent.contains(TaxFormType.w4)) { - await _createInitialForm(staffId, TaxFormType.w4); - createdNew = true; - } - - if (createdNew) { + return executeProtected(() async { + final String staffId = _getStaffId(); final QueryResult - result2 = - await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute(); - return result2.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); - } + result = await dataConnect + .getTaxFormsByStaffId(staffId: staffId) + .execute(); - return forms; + final List forms = + result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); + + // Check if required forms exist, create if not. + final Set typesPresent = + forms.map((TaxForm f) => f.type).toSet(); + bool createdNew = false; + + if (!typesPresent.contains(TaxFormType.i9)) { + await _createInitialForm(staffId, TaxFormType.i9); + createdNew = true; + } + if (!typesPresent.contains(TaxFormType.w4)) { + await _createInitialForm(staffId, TaxFormType.w4); + createdNew = true; + } + + if (createdNew) { + final QueryResult< + dc.GetTaxFormsByStaffIdData, + dc.GetTaxFormsByStaffIdVariables> result2 = + await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute(); + return result2.data.taxForms + .map(TaxFormMapper.fromDataConnect) + .toList(); + } + + return forms; + }); } Future _createInitialForm(String staffId, TaxFormType type) async { @@ -80,45 +95,62 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { @override Future updateI9Form(I9TaxForm form) async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapI9Fields(builder, data); - await builder.execute(); + return executeProtected(() async { + final Map data = form.formData; + final dc.UpdateTaxFormVariablesBuilder builder = + dataConnect.updateTaxForm(id: form.id); + _mapCommonFields(builder, data); + _mapI9Fields(builder, data); + await builder.execute(); + }); } @override Future submitI9Form(I9TaxForm form) async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapI9Fields(builder, data); - await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); + return executeProtected(() async { + final Map data = form.formData; + final dc.UpdateTaxFormVariablesBuilder builder = + dataConnect.updateTaxForm(id: form.id); + _mapCommonFields(builder, data); + _mapI9Fields(builder, data); + await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); + }); } @override Future updateW4Form(W4TaxForm form) async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapW4Fields(builder, data); - await builder.execute(); + return executeProtected(() async { + final Map data = form.formData; + final dc.UpdateTaxFormVariablesBuilder builder = + dataConnect.updateTaxForm(id: form.id); + _mapCommonFields(builder, data); + _mapW4Fields(builder, data); + await builder.execute(); + }); } @override Future submitW4Form(W4TaxForm form) async { - final Map data = form.formData; - final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); - _mapCommonFields(builder, data); - _mapW4Fields(builder, data); - await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); + return executeProtected(() async { + final Map data = form.formData; + final dc.UpdateTaxFormVariablesBuilder builder = + dataConnect.updateTaxForm(id: form.id); + _mapCommonFields(builder, data); + _mapW4Fields(builder, data); + await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); + }); } - void _mapCommonFields(dc.UpdateTaxFormVariablesBuilder builder, Map data) { - if (data.containsKey('firstName')) builder.firstName(data['firstName'] as String?); - if (data.containsKey('lastName')) builder.lastName(data['lastName'] as String?); - if (data.containsKey('middleInitial')) builder.mInitial(data['middleInitial'] as String?); - if (data.containsKey('otherLastNames')) builder.oLastName(data['otherLastNames'] as String?); + void _mapCommonFields( + dc.UpdateTaxFormVariablesBuilder builder, Map data) { + if (data.containsKey('firstName')) + builder.firstName(data['firstName'] as String?); + if (data.containsKey('lastName')) + builder.lastName(data['lastName'] as String?); + if (data.containsKey('middleInitial')) + builder.mInitial(data['middleInitial'] as String?); + if (data.containsKey('otherLastNames')) + builder.oLastName(data['otherLastNames'] as String?); if (data.containsKey('dob')) { final String dob = data['dob'] as String; // Handle both ISO string and MM/dd/yyyy manual entry @@ -131,8 +163,8 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { final List parts = dob.split('/'); if (parts.length == 3) { date = DateTime( - int.parse(parts[2]), - int.parse(parts[0]), + int.parse(parts[2]), + int.parse(parts[0]), int.parse(parts[1]), ); } @@ -145,70 +177,90 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository { } } if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) { - builder.socialSN(int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0); + builder.socialSN( + int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? + 0); } if (data.containsKey('email')) builder.email(data['email'] as String?); if (data.containsKey('phone')) builder.phone(data['phone'] as String?); - if (data.containsKey('address')) builder.address(data['address'] as String?); - if (data.containsKey('aptNumber')) builder.apt(data['aptNumber'] as String?); + if (data.containsKey('address')) + builder.address(data['address'] as String?); + if (data.containsKey('aptNumber')) + builder.apt(data['aptNumber'] as String?); if (data.containsKey('city')) builder.city(data['city'] as String?); if (data.containsKey('state')) builder.state(data['state'] as String?); - if (data.containsKey('zipCode')) builder.zipCode(data['zipCode'] as String?); + if (data.containsKey('zipCode')) + builder.zipCode(data['zipCode'] as String?); } - void _mapI9Fields(dc.UpdateTaxFormVariablesBuilder builder, Map data) { + void _mapI9Fields( + dc.UpdateTaxFormVariablesBuilder builder, Map data) { if (data.containsKey('citizenshipStatus')) { final String status = data['citizenshipStatus'] as String; // Map string to enum if possible, or handle otherwise. // Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED try { - builder.citizen(dc.CitizenshipStatus.values.byName(status.toUpperCase())); + builder.citizen( + dc.CitizenshipStatus.values.byName(status.toUpperCase())); } catch (_) {} } - if (data.containsKey('uscisNumber')) builder.uscis(data['uscisNumber'] as String?); - if (data.containsKey('passportNumber')) builder.passportNumber(data['passportNumber'] as String?); - if (data.containsKey('countryIssuance')) builder.countryIssue(data['countryIssuance'] as String?); - if (data.containsKey('preparerUsed')) builder.prepartorOrTranslator(data['preparerUsed'] as bool?); - if (data.containsKey('signature')) builder.signature(data['signature'] as String?); + if (data.containsKey('uscisNumber')) + builder.uscis(data['uscisNumber'] as String?); + if (data.containsKey('passportNumber')) + builder.passportNumber(data['passportNumber'] as String?); + if (data.containsKey('countryIssuance')) + builder.countryIssue(data['countryIssuance'] as String?); + if (data.containsKey('preparerUsed')) + builder.prepartorOrTranslator(data['preparerUsed'] as bool?); + if (data.containsKey('signature')) + builder.signature(data['signature'] as String?); // Note: admissionNumber not in builder based on file read } - void _mapW4Fields(dc.UpdateTaxFormVariablesBuilder builder, Map data) { + void _mapW4Fields( + dc.UpdateTaxFormVariablesBuilder builder, Map data) { if (data.containsKey('cityStateZip')) { - final String csz = data['cityStateZip'] as String; - // Extremely basic split: City, State Zip - final List parts = csz.split(','); - if (parts.length >= 2) { - builder.city(parts[0].trim()); - final String stateZip = parts[1].trim(); - final List szParts = stateZip.split(' '); - if (szParts.isNotEmpty) builder.state(szParts[0]); - if (szParts.length > 1) builder.zipCode(szParts.last); - } + final String csz = data['cityStateZip'] as String; + // Extremely basic split: City, State Zip + final List parts = csz.split(','); + if (parts.length >= 2) { + builder.city(parts[0].trim()); + final String stateZip = parts[1].trim(); + final List szParts = stateZip.split(' '); + if (szParts.isNotEmpty) builder.state(szParts[0]); + if (szParts.length > 1) builder.zipCode(szParts.last); + } } if (data.containsKey('filingStatus')) { // MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD - try { - final String status = data['filingStatus'] as String; - // Simple mapping assumptions: - if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE); - else if (status.contains('married')) builder.marital(dc.MaritalStatus.MARRIED); - else if (status.contains('head')) builder.marital(dc.MaritalStatus.HEAD); - } catch (_) {} + try { + final String status = data['filingStatus'] as String; + // Simple mapping assumptions: + if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE); + else if (status.contains('married')) + builder.marital(dc.MaritalStatus.MARRIED); + else if (status.contains('head')) + builder.marital(dc.MaritalStatus.HEAD); + } catch (_) {} } - if (data.containsKey('multipleJobs')) builder.multipleJob(data['multipleJobs'] as bool?); - if (data.containsKey('qualifyingChildren')) builder.childrens(data['qualifyingChildren'] as int?); - if (data.containsKey('otherDependents')) builder.otherDeps(data['otherDependents'] as int?); + if (data.containsKey('multipleJobs')) + builder.multipleJob(data['multipleJobs'] as bool?); + if (data.containsKey('qualifyingChildren')) + builder.childrens(data['qualifyingChildren'] as int?); + if (data.containsKey('otherDependents')) + builder.otherDeps(data['otherDependents'] as int?); if (data.containsKey('otherIncome')) { - builder.otherInconme(double.tryParse(data['otherIncome'].toString())); + builder.otherInconme(double.tryParse(data['otherIncome'].toString())); } if (data.containsKey('deductions')) { - builder.deductions(double.tryParse(data['deductions'].toString())); + builder.deductions(double.tryParse(data['deductions'].toString())); } if (data.containsKey('extraWithholding')) { - builder.extraWithholding(double.tryParse(data['extraWithholding'].toString())); + builder.extraWithholding( + double.tryParse(data['extraWithholding'].toString())); } - if (data.containsKey('signature')) builder.signature(data['signature'] as String?); + if (data.containsKey('signature')) + builder.signature(data['signature'] as String?); } } 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/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index b6957ca6..b1d6c6ac 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -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'; @@ -62,7 +63,15 @@ class TaxFormsPage extends StatelessWidget { if (state.status == TaxFormsStatus.failure) { return Center( - child: Text(state.errorMessage ?? 'Error loading forms'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'Error loading forms', + textAlign: TextAlign.center, + ), + ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart index f98e7c15..e2957389 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart @@ -5,7 +5,9 @@ import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/bank_account_repository.dart'; /// Implementation of [BankAccountRepository] that integrates with Data Connect. -class BankAccountRepositoryImpl implements BankAccountRepository { +class BankAccountRepositoryImpl + with DataErrorHandler + implements BankAccountRepository { /// Creates a [BankAccountRepositoryImpl]. const BankAccountRepositoryImpl({ required this.dataConnect, @@ -19,60 +21,65 @@ class BankAccountRepositoryImpl implements BankAccountRepository { @override Future> getAccounts() async { - final String staffId = _getStaffId(); - - final QueryResult - result = await dataConnect - .getAccountsByOwnerId(ownerId: staffId) - .execute(); - - return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { - return BankAccountAdapter.fromPrimitives( - id: account.id, - userId: account.ownerId, - bankName: account.bank, - accountNumber: account.accountNumber, - last4: account.last4, - sortCode: account.routeNumber, - type: account.type is Known ? (account.type as Known).value.name : null, - isPrimary: account.isPrimary, - ); - }).toList(); + return executeProtected(() async { + final String staffId = _getStaffId(); + + final QueryResult + result = await dataConnect + .getAccountsByOwnerId(ownerId: staffId) + .execute(); + + return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { + return BankAccountAdapter.fromPrimitives( + id: account.id, + userId: account.ownerId, + bankName: account.bank, + accountNumber: account.accountNumber, + last4: account.last4, + sortCode: account.routeNumber, + type: account.type is Known ? (account.type as Known).value.name : null, + isPrimary: account.isPrimary, + ); + }).toList(); + }); } @override Future addAccount(BankAccount account) async { - final String staffId = _getStaffId(); + return executeProtected(() async { + final String staffId = _getStaffId(); - final QueryResult - existingAccounts = await dataConnect - .getAccountsByOwnerId(ownerId: staffId) - .execute(); - final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty; - final bool isPrimary = !hasAccounts; + final QueryResult + existingAccounts = await dataConnect + .getAccountsByOwnerId(ownerId: staffId) + .execute(); + final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty; + final bool isPrimary = !hasAccounts; - await dataConnect.createAccount( - bank: account.bankName, - type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)), - last4: _safeLast4(account.last4, account.accountNumber), - ownerId: staffId, - ) - .isPrimary(isPrimary) - .accountNumber(account.accountNumber) - .routeNumber(account.sortCode) - .execute(); + await dataConnect.createAccount( + bank: account.bankName, + type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)), + last4: _safeLast4(account.last4, account.accountNumber), + ownerId: staffId, + ) + .isPrimary(isPrimary) + .accountNumber(account.accountNumber) + .routeNumber(account.sortCode) + .execute(); + }); } /// Helper to get the logged-in staff ID. String _getStaffId() { final auth.User? user = firebaseAuth.currentUser; if (user == null) { - throw Exception('User not authenticated'); + throw const NotAuthenticatedException( + technicalMessage: 'User not authenticated'); } final String? staffId = StaffSessionStore.instance.session?.staff?.id; if (staffId == null || staffId.isEmpty) { - throw Exception('Staff profile is missing or session not initialized.'); + throw const ServerException(technicalMessage: 'Staff profile is missing or session not initialized.'); } return staffId; } 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 210e7502..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,18 +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), - ), + 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) { @@ -67,7 +63,18 @@ class BankAccountPage extends StatelessWidget { } if (state.status == BankAccountStatus.error) { - return Center(child: Text(state.errorMessage ?? 'Error')); + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'Error', + textAlign: TextAlign.center, + style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), + ), + ), + ); } return Column( 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/data/repositories_impl/time_card_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart index 4a6aba1c..15823f5b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart @@ -9,7 +9,9 @@ import 'package:krow_core/core.dart'; import '../../domain/repositories/time_card_repository.dart'; /// Implementation of [TimeCardRepository] using Firebase Data Connect. -class TimeCardRepositoryImpl implements TimeCardRepository { +class TimeCardRepositoryImpl + with dc.DataErrorHandler + implements TimeCardRepository { final dc.ExampleConnector _dataConnect; final firebase.FirebaseAuth _firebaseAuth; @@ -22,57 +24,62 @@ class TimeCardRepositoryImpl implements TimeCardRepository { Future _getStaffId() async { final firebase.User? user = _firebaseAuth.currentUser; - if (user == null) throw Exception('User not authenticated'); + if (user == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } final fdc.QueryResult result = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); if (result.data.staffs.isEmpty) { - throw Exception('Staff profile not found'); + throw const ServerException(technicalMessage: 'Staff profile not found'); } return result.data.staffs.first.id; } @override Future> getTimeCards(DateTime month) async { - final String staffId = await _getStaffId(); - // Fetch applications. Limit can be adjusted, assuming 100 is safe for now. - final fdc.QueryResult result = - await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute(); + return executeProtected(() async { + final String staffId = await _getStaffId(); + // Fetch applications. Limit can be adjusted, assuming 100 is safe for now. + final fdc.QueryResult result = + await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute(); - return result.data.applications - .where((dc.GetApplicationsByStaffIdApplications app) { - final DateTime? shiftDate = app.shift.date == null - ? null - : DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); - if (shiftDate == null) return false; - return shiftDate.year == month.year && shiftDate.month == month.month; - }) - .map((dc.GetApplicationsByStaffIdApplications app) { - final DateTime shiftDate = - DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); - final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? ''; - final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? ''; + return result.data.applications + .where((dc.GetApplicationsByStaffIdApplications app) { + final DateTime? shiftDate = app.shift.date == null + ? null + : DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); + if (shiftDate == null) return false; + return shiftDate.year == month.year && shiftDate.month == month.month; + }) + .map((dc.GetApplicationsByStaffIdApplications app) { + final DateTime shiftDate = + DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); + final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? ''; + final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? ''; - // Prefer shiftRole values for pay/hours - final double hours = app.shiftRole.hours ?? 0.0; - final double rate = app.shiftRole.role.costPerHour; - final double pay = app.shiftRole.totalValue ?? 0.0; + // Prefer shiftRole values for pay/hours + final double hours = app.shiftRole.hours ?? 0.0; + final double rate = app.shiftRole.role.costPerHour; + final double pay = app.shiftRole.totalValue ?? 0.0; - return TimeCardAdapter.fromPrimitives( - id: app.id, - shiftTitle: app.shift.title, - clientName: app.shift.order.business.businessName, - date: shiftDate, - startTime: startTime, - endTime: endTime, - totalHours: hours, - hourlyRate: rate, - totalPay: pay, - status: app.status.stringValue, - location: app.shift.location, - ); - }) - .toList(); + return TimeCardAdapter.fromPrimitives( + id: app.id, + shiftTitle: app.shift.title, + clientName: app.shift.order.business.businessName, + date: shiftDate, + startTime: startTime, + endTime: endTime, + totalHours: hours, + hourlyRate: rate, + totalPay: pay, + status: app.status.stringValue, + location: app.shift.location, + ); + }) + .toList(); + }); } String? _formatTime(fdc.Timestamp? timestamp) { 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 02b670c4..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 @@ -27,6 +27,7 @@ class _TimeCardPageState extends State { @override Widget build(BuildContext context) { + final t = Translations.of(context); return BlocProvider.value( value: _bloc, child: Scaffold( @@ -47,12 +48,30 @@ class _TimeCardPageState extends State { child: Container(color: UiColors.border, height: 1.0), ), ), - body: BlocBuilder( + body: BlocConsumer( + listener: (context, state) { + if (state is TimeCardError) { + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, + ); + } + }, builder: (context, state) { if (state is TimeCardLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is TimeCardError) { - return Center(child: Text('Error: ${state.message}')); + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + translateErrorKey(state.message), + textAlign: TextAlign.center, + style: UiTypography.body1m.copyWith(color: UiColors.textSecondary), + ), + ), + ); } else if (state is TimeCardLoaded) { return SingleChildScrollView( padding: const EdgeInsets.symmetric( 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/data/repositories/emergency_contact_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart index ccbb3be4..c3ec4792 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart @@ -7,6 +7,7 @@ import '../../domain/repositories/emergency_contact_repository_interface.dart'; /// /// This repository delegates data operations to Firebase Data Connect. class EmergencyContactRepositoryImpl + with dc.DataErrorHandler implements EmergencyContactRepositoryInterface { final dc.ExampleConnector _dataConnect; final FirebaseAuth _firebaseAuth; @@ -20,64 +21,81 @@ class EmergencyContactRepositoryImpl Future _getStaffId() async { final user = _firebaseAuth.currentUser; - if (user == null) throw Exception('User not authenticated'); + if (user == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } final result = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); if (result.data.staffs.isEmpty) { - throw Exception('Staff profile not found'); + throw const ServerException(technicalMessage: 'Staff profile not found'); } return result.data.staffs.first.id; } @override Future> getContacts() async { - final staffId = await _getStaffId(); - final result = - await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute(); + return executeProtected(() async { + final staffId = await _getStaffId(); + final result = await _dataConnect + .getEmergencyContactsByStaffId(staffId: staffId) + .execute(); - return result.data.emergencyContacts.map((dto) { - return EmergencyContactAdapter.fromPrimitives( - id: dto.id, - name: dto.name, - phone: dto.phone, - relationship: dto.relationship.stringValue, - ); - }).toList(); + return result.data.emergencyContacts.map((dto) { + return EmergencyContactAdapter.fromPrimitives( + id: dto.id, + name: dto.name, + phone: dto.phone, + relationship: dto.relationship.stringValue, + ); + }).toList(); + }); } @override Future saveContacts(List contacts) async { - final staffId = await _getStaffId(); + return executeProtected(() async { + final staffId = await _getStaffId(); - // 1. Get existing to delete - final existingResult = - await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute(); - final existingIds = - existingResult.data.emergencyContacts.map((e) => e.id).toList(); - - // 2. Delete all existing - await Future.wait(existingIds.map( - (id) => _dataConnect.deleteEmergencyContact(id: id).execute())); - - // 3. Create new - await Future.wait(contacts.map((contact) { - dc.RelationshipType rel = dc.RelationshipType.OTHER; - switch(contact.relationship) { - case RelationshipType.family: rel = dc.RelationshipType.FAMILY; break; - case RelationshipType.spouse: rel = dc.RelationshipType.SPOUSE; break; - case RelationshipType.friend: rel = dc.RelationshipType.FRIEND; break; - case RelationshipType.other: rel = dc.RelationshipType.OTHER; break; - } - - return _dataConnect - .createEmergencyContact( - name: contact.name, - phone: contact.phone, - relationship: rel, - staffId: staffId, - ) + // 1. Get existing to delete + final existingResult = await _dataConnect + .getEmergencyContactsByStaffId(staffId: staffId) .execute(); - })); + final existingIds = + existingResult.data.emergencyContacts.map((e) => e.id).toList(); + + // 2. Delete all existing + await Future.wait(existingIds.map( + (id) => _dataConnect.deleteEmergencyContact(id: id).execute())); + + // 3. Create new + await Future.wait(contacts.map((contact) { + dc.RelationshipType rel = dc.RelationshipType.OTHER; + switch (contact.relationship) { + case RelationshipType.family: + rel = dc.RelationshipType.FAMILY; + break; + case RelationshipType.spouse: + rel = dc.RelationshipType.SPOUSE; + break; + case RelationshipType.friend: + rel = dc.RelationshipType.FRIEND; + break; + case RelationshipType.other: + rel = dc.RelationshipType.OTHER; + break; + } + + return _dataConnect + .createEmergencyContact( + name: contact.name, + phone: contact.phone, + relationship: rel, + staffId: staffId, + ) + .execute(); + })); + }); } } \ No newline at end of file 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 652ff125..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 @@ -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'; @@ -8,7 +9,6 @@ import '../widgets/emergency_contact_form_item.dart'; import '../widgets/emergency_contact_info_banner.dart'; import '../widgets/emergency_contact_save_button.dart'; - /// The Staff Emergency Contact screen. /// /// This screen allows staff to manage their emergency contacts during onboarding. @@ -19,6 +19,7 @@ class EmergencyContactScreen extends StatelessWidget { @override Widget build(BuildContext context) { + Translations.of(context); // Force rebuild on locale change return Scaffold( appBar: AppBar( elevation: 0, @@ -38,10 +39,16 @@ class EmergencyContactScreen extends StatelessWidget { body: BlocProvider( create: (context) => Modular.get(), child: BlocConsumer( + listener: (context, state) { if (state.status == EmergencyContactStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.errorMessage ?? 'An error occurred')), + 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/data/repositories/experience_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart index 49af4cb9..159dd31f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart @@ -2,13 +2,17 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import '../../domain/repositories/experience_repository_interface.dart'; +import 'package:krow_domain/krow_domain.dart'; + /// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect. -class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { +class ExperienceRepositoryImpl + with dc.DataErrorHandler + implements ExperienceRepositoryInterface { final dc.ExampleConnector _dataConnect; // ignore: unused_field final FirebaseAuth _firebaseAuth; - /// Creates a [ExperienceRepositoryImpl] using Da a Connect and Auth. + /// Creates a [ExperienceRepositoryImpl] using Data Connect and Auth. ExperienceRepositoryImpl({ required dc.ExampleConnector dataConnect, required FirebaseAuth firebaseAuth, @@ -17,26 +21,33 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { Future _getStaff() async { final user = _firebaseAuth.currentUser; - if (user == null) throw Exception('User not authenticated'); + if (user == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } final result = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); if (result.data.staffs.isEmpty) { - throw Exception('Staff profile not found'); + throw const ServerException(technicalMessage: 'Staff profile not found'); } return result.data.staffs.first; } @override Future> getIndustries() async { - final staff = await _getStaff(); - return staff.industries ?? []; + return executeProtected(() async { + final staff = await _getStaff(); + return staff.industries ?? []; + }); } @override Future> getSkills() async { - final staff = await _getStaff(); - return staff.skills ?? []; + return executeProtected(() async { + final staff = await _getStaff(); + return staff.skills ?? []; + }); } @override @@ -44,15 +55,13 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { List industries, List skills, ) async { - try { - final staff = await _getStaff(); - await _dataConnect - .updateStaff(id: staff.id) - .industries(industries) - .skills(skills) - .execute(); - } catch (e) { - throw Exception('Failed to save experience: $e'); - } + return executeProtected(() async { + final staff = await _getStaff(); + await _dataConnect + .updateStaff(id: staff.id) + .industries(industries) + .skills(skills) + .execute(); + }); } } 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 cd3cada5..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 @@ -46,7 +46,7 @@ class ExperiencePage extends StatelessWidget { @override Widget build(BuildContext context) { - final i18n = t.staff.onboarding.experience; + final i18n = Translations.of(context).staff.onboarding.experience; return Scaffold( appBar: UiAppBar( @@ -58,13 +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 ?? 'An error occurred')), + 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/data/repositories/personal_info_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart index 9a8045cf..e2e4b5ba 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart @@ -13,7 +13,9 @@ import '../../domain/repositories/personal_info_repository_interface.dart'; /// - Delegating all data access to the data_connect layer /// - Mapping between data_connect DTOs and domain entities /// - Containing no business logic -class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { +class PersonalInfoRepositoryImpl + with DataErrorHandler + implements PersonalInfoRepositoryInterface { /// Creates a [PersonalInfoRepositoryImpl]. /// @@ -28,58 +30,63 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { @override Future getStaffProfile() async { - final firebase_auth.User? user = _firebaseAuth.currentUser; - if (user == null) { - throw Exception('User not authenticated'); - } + return executeProtected(() async { + final firebase_auth.User? user = _firebaseAuth.currentUser; + if (user == null) { + throw NotAuthenticatedException( + technicalMessage: 'User not authenticated'); + } - // Query staff data from Firebase Data Connect - final QueryResult result = - await _dataConnect.getStaffByUserId(userId: user.uid).execute(); + // Query staff data from Firebase Data Connect + final QueryResult result = + await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (result.data.staffs.isEmpty) { - throw Exception('Staff profile not found for User ID: ${user.uid}'); - } + if (result.data.staffs.isEmpty) { + throw const ServerException(technicalMessage: 'Staff profile not found'); + } - final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first; + final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first; - // Map from data_connect DTO to domain entity - return _mapToStaffEntity(rawStaff); + // Map from data_connect DTO to domain entity + return _mapToStaffEntity(rawStaff); + }); } @override Future updateStaffProfile({required String staffId, required Map data}) async { - // Start building the update mutation - UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId); + return executeProtected(() async { + // Start building the update mutation + UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId); - // Apply updates from map if present - if (data.containsKey('name')) { - updateBuilder = updateBuilder.fullName(data['name'] as String); - } - if (data.containsKey('email')) { - updateBuilder = updateBuilder.email(data['email'] as String); - } - if (data.containsKey('phone')) { - updateBuilder = updateBuilder.phone(data['phone'] as String?); - } - if (data.containsKey('avatar')) { - updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?); - } - if (data.containsKey('preferredLocations')) { - // After schema update and SDK regeneration, preferredLocations accepts List - updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List); - } + // Apply updates from map if present + if (data.containsKey('name')) { + updateBuilder = updateBuilder.fullName(data['name'] as String); + } + if (data.containsKey('email')) { + updateBuilder = updateBuilder.email(data['email'] as String); + } + if (data.containsKey('phone')) { + updateBuilder = updateBuilder.phone(data['phone'] as String?); + } + if (data.containsKey('avatar')) { + updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?); + } + if (data.containsKey('preferredLocations')) { + // After schema update and SDK regeneration, preferredLocations accepts List + updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List); + } - // Execute the update - final OperationResult result = - await updateBuilder.execute(); + // Execute the update + final OperationResult result = + await updateBuilder.execute(); - if (result.data.staff_update == null) { - throw Exception('Failed to update staff profile'); - } + if (result.data.staff_update == null) { + throw const ServerException(technicalMessage: 'Failed to update staff profile'); + } - // Fetch the updated staff profile to return complete entity - return getStaffProfile(); + // Fetch the updated staff profile to return complete entity + return getStaffProfile(); + }); } @override 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 c28e3593..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 @@ -22,26 +22,25 @@ class PersonalInfoPage extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; + final i18n = Translations.of(context).staff.onboarding.personal_info; return BlocProvider( create: (BuildContext context) => Modular.get(), 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 ?? 'An error occurred'), - backgroundColor: UiColors.destructive, - 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 9f4caa2e..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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:design_system/design_system.dart'; +import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/shifts/shifts_bloc.dart'; import '../widgets/tabs/my_shifts_tab.dart'; @@ -65,9 +66,19 @@ class _ShiftsPageState extends State { @override Widget build(BuildContext context) { + final t = Translations.of(context); return BlocProvider.value( value: _bloc, - child: BlocBuilder( + child: BlocConsumer( + listener: (context, state) { + if (state is ShiftsError) { + UiSnackbar.show( + context, + message: translateErrorKey(state.message), + type: UiSnackbarType.error, + ); + } + }, builder: (context, state) { final bool baseLoaded = state is ShiftsLoaded; final List myShifts = (state is ShiftsLoaded) @@ -123,7 +134,7 @@ class _ShiftsPageState extends State { spacing: UiConstants.space4, children: [ Text( - "Shifts", + t.staff_shifts.title, style: UiTypography.display1b.white, ), @@ -132,7 +143,7 @@ class _ShiftsPageState extends State { children: [ _buildTab( "myshifts", - "My Shifts", + t.staff_shifts.tabs.my_shifts, UiIcons.calendar, myShifts.length, showCount: myShiftsLoaded, @@ -141,7 +152,7 @@ class _ShiftsPageState extends State { const SizedBox(width: UiConstants.space2), _buildTab( "find", - "Find Shifts", + t.staff_shifts.tabs.find_work, UiIcons.search, availableJobs.length, showCount: availableLoaded, @@ -150,7 +161,7 @@ class _ShiftsPageState extends State { const SizedBox(width: UiConstants.space2), _buildTab( "history", - "History", + t.staff_shifts.tabs.history, UiIcons.clock, historyShifts.length, showCount: historyLoaded, @@ -166,7 +177,26 @@ class _ShiftsPageState extends State { Expanded( child: state is ShiftsLoading ? const Center(child: CircularProgressIndicator()) - : _buildTabContent( + : state is ShiftsError + ? Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + translateErrorKey(state.message), + style: const TextStyle( + fontSize: 16, + color: Color(0xFF64748B), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : _buildTabContent( myShifts, pendingAssignments, cancelledShifts, 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/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart index 4655cf4d..49c71126 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart @@ -36,6 +36,7 @@ class StaffMainBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + final t = Translations.of(context); // Staff App colors from design system // Using primary (Blue) for active as per prototype const Color activeColor = UiColors.primary; diff --git a/docs/ERROR_HANDLING_ARCHITECTURE.md b/docs/ERROR_HANDLING_ARCHITECTURE.md new file mode 100644 index 00000000..095f350c --- /dev/null +++ b/docs/ERROR_HANDLING_ARCHITECTURE.md @@ -0,0 +1,358 @@ +# Centralized Error Handling Architecture +**Project:** KROW Workforce Mobile App + +## 1. Executive Summary +We have implemented a **Centralized Error Handling System** that ensures the entire application (Staff & Client) handles errors consistently, reliably, and with full localization support. + +Instead of writing error handling code in every single feature (which leads to bugs and inconsistent messages), we rely on a global safety net that catches **Network Failures**, **Server Errors (500)**, **Not Found Errors (404)**, and **Business Logic Violations** automatically. + +### Key Benefits +* **Safety:** The app never crashes due to unhandled API errors. +* **Consistency:** A network error looks the same in "Shifts" as it does in "billing". +* **Localization:** All error messages are automatically translated (English/Spanish). +* **Speed:** Developers can build features faster without worrying about `try/catch` blocks. + +### Technical Excellence (Status Code Handling) +We don't just catch "errors"; we understand them. The system automatically categorizes and handles: +* **500 (Server Error):** "Our servers are having a moment. Please try again." +* **404 (Not Found):** "The resource you're looking for (Shift/Profile) is missing." +* **401 (Unauthorized):** "Your session expired. Please log in again." (Auto-redirect) +* **403 (Forbidden):** "You don't have permission to access this area." +* **503 (Unavailable):** "Maintenance mode or overloaded. Back in a bit!" + +--- + +## 2. Architecture Overview + +The error handling flows through three distinct layers, ensuring separation of concerns: + +```mermaid +graph TD + A[Data Layer / Repository] -->|Throws AppException| B[BLoC Layer / State Management] + B -->|Emits Error Key| C[UI Layer / Presentation] + + subgraph "1. Data Layer (The Guard)" + A -- Captures Exceptions --> D[DataErrorHandler Mixin] + D -- Maps to --> E[NetworkException, ServerException, etc.] + end + + subgraph "2. BLoC Layer (The Translator)" + B -- Uses --> F[BlocErrorHandler Mixin] + F -- Catches AppException --> G[converts to 'errors.category.type'] + end + + subgraph "3. UI Layer (The Messenger)" + C -- Calls --> H["translateErrorKey()"] + H -- Returns --> I["Localized String (e.g. 'Sin conexión')"] + end +``` + +### 1. The Data Layer (The Guard) +**Location:** `packages/data_connect/lib/src/mixins/data_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. + +**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. Real-World Example: Submitting a Tax Form + +Let's trace what happens when a user submits Form W-4 with no internet: + +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. Simple Verification Tests (For Non-Developers) + +### Test A: The "Tunnel" Test (Network) +1. Open the app to the **Shifts** page. +2. Toggle **Airplane Mode ON**. +3. Pull to refresh the list. +4. **Result:** App shows a gentle `snackbar` error: *"No internet connection"* (or Spanish equivalent). **No Crash.** + +### Test B: The "Duplicate Data" Test (Smart Validation) +1. Log in on two devices with the same account (if possible) or simply use a known registered email. +2. Go to the **Sign Up** page. +3. Try to register a new account using that *existing* email. +4. **Result:** App instantly displays specific, helpful feedback: *"An account with this email already exists."* instead of a generic failure. +5. **Why it matters:** Proves the backend and frontend are synced to guide the user, not just block them. + +### Test C: The "Crash Proof" Test (The Safety Net) +1. **Scenario:** Even if a developer introduces a bug (like a random exception) or the server returns a 500 status. +2. **Result:** The app catches the unknown error, logs it internally, and shows a safe default message: *"Something went wrong. Please try again."* +3. **Why it matters:** The app never crashes or closes unexpectedly, preserving user trust. + +### Test D: The "Language" Test (Localization) +1. Trigger an error (like wrong password). +2. Change phone language to **Spanish**. +3. Trigger the same error. +4. **Result:** Message automatically translates: *"Correo electrónico o contraseña inválidos."* + +--- + +## 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` +* **Translator:** `packages/core_localization/lib/src/utils/error_translator.dart` +* **Strings:** `packages/core_localization/lib/src/l10n/*.i18n.json`