Merge pull request #396 from Oloodi/feature/centralized-data-error-handling

feat: Centralized Error Handling Architecture & Fix Intermittent Crashes (#375, #376, #378, #395)
This commit is contained in:
Achintha Isuru
2026-02-11 11:06:40 -05:00
committed by GitHub
71 changed files with 3170 additions and 1485 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -118,3 +119,21 @@ mixin BlocErrorHandler<S> {
} }
} }
} }
/// 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<E, S> on Bloc<E, S> {
@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
);
}
}
}

View File

@@ -65,27 +65,27 @@ class StaffPaths {
/// Home tab - the main dashboard for staff. /// Home tab - the main dashboard for staff.
/// ///
/// Displays shift cards, quick actions, and notifications. /// 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. /// Shifts tab - view and manage shifts.
/// ///
/// Browse available shifts, accepted shifts, and shift history. /// 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. /// Payments tab - view payment history and earnings.
/// ///
/// Access payment history, earnings breakdown, and tax information. /// 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. /// Clock In tab - clock in/out functionality.
/// ///
/// Time tracking interface for active shifts. /// 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. /// Profile tab - staff member profile and settings.
/// ///
/// Manage personal information, documents, and preferences. /// Manage personal information, documents, and preferences.
static const String profile = '/worker-main/profile'; static const String profile = '/worker-main/profile/';
// ========================================================================== // ==========================================================================
// SHIFT MANAGEMENT // SHIFT MANAGEMENT
@@ -113,22 +113,22 @@ class StaffPaths {
/// ///
/// Collect basic personal information during staff onboarding. /// Collect basic personal information during staff onboarding.
static const String onboardingPersonalInfo = static const String onboardingPersonalInfo =
'/worker-main/onboarding/personal-info'; '/worker-main/onboarding/personal-info/';
/// Emergency contact information. /// Emergency contact information.
/// ///
/// Manage emergency contact details for safety purposes. /// 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. /// Work experience information.
/// ///
/// Record previous work experience and qualifications. /// Record previous work experience and qualifications.
static const String experience = '/worker-main/experience'; static const String experience = '/worker-main/experience/';
/// Attire and appearance preferences. /// Attire and appearance preferences.
/// ///
/// Record sizing and appearance information for uniform allocation. /// Record sizing and appearance information for uniform allocation.
static const String attire = '/worker-main/attire'; static const String attire = '/worker-main/attire/';
// ========================================================================== // ==========================================================================
// COMPLIANCE & DOCUMENTS // COMPLIANCE & DOCUMENTS
@@ -137,12 +137,12 @@ class StaffPaths {
/// Documents management - upload and manage required documents. /// Documents management - upload and manage required documents.
/// ///
/// Store ID, work permits, and other required documentation. /// 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. /// Certificates management - professional certifications.
/// ///
/// Manage professional certificates (e.g., food handling, CPR, etc.). /// 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 // FINANCIAL INFORMATION
@@ -151,12 +151,12 @@ class StaffPaths {
/// Bank account information for direct deposit. /// Bank account information for direct deposit.
/// ///
/// Manage banking details for payment processing. /// 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. /// Tax forms and withholding information.
/// ///
/// Manage W-4, tax withholding, and related tax documents. /// 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. /// Form I-9 - Employment Eligibility Verification.
/// ///
@@ -171,7 +171,7 @@ class StaffPaths {
/// Time card - view detailed time tracking records. /// Time card - view detailed time tracking records.
/// ///
/// Access detailed time entries and timesheets. /// Access detailed time entries and timesheets.
static const String timeCard = '/worker-main/time-card'; static const String timeCard = '/worker-main/time-card/';
// ========================================================================== // ==========================================================================
// SCHEDULING & AVAILABILITY // SCHEDULING & AVAILABILITY
@@ -180,7 +180,7 @@ class StaffPaths {
/// Availability management - set working hours preferences. /// Availability management - set working hours preferences.
/// ///
/// Define when the staff member is available to work. /// 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) // ADDITIONAL FEATURES (Placeholders)

View File

@@ -11,6 +11,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_bloc: ^8.1.0
design_system: design_system:
path: ../design_system path: ../design_system
equatable: ^2.0.8 equatable: ^2.0.8

View File

@@ -593,6 +593,243 @@
"driving": "Driving" "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": { "staff_documents": {
@@ -680,7 +917,8 @@
"title": "Shifts", "title": "Shifts",
"tabs": { "tabs": {
"my_shifts": "My Shifts", "my_shifts": "My Shifts",
"find_work": "Find Work" "find_work": "Find Shifts",
"history": "History"
}, },
"list": { "list": {
"no_shifts": "No shifts found", "no_shifts": "No shifts found",
@@ -716,6 +954,40 @@
"tags": { "tags": {
"immediate_start": "Immediate start", "immediate_start": "Immediate start",
"no_experience": "No experience" "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": { "staff_time_card": {
@@ -735,7 +1007,7 @@
}, },
"errors": { "errors": {
"auth": { "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.", "account_exists": "An account with this email already exists. Try signing in instead.",
"session_expired": "Your session has expired. Please sign in again.", "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.", "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_up_failed": "We couldn't create your account. Please try again.",
"sign_in_failed": "We couldn't sign you in. Please try again.", "sign_in_failed": "We couldn't sign you in. Please try again.",
"not_authenticated": "Please sign in to continue.", "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.", "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." "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": { "profile": {
"updated": "Profile updated successfully!" "updated": "Profile updated successfully!"
},
"availability": {
"updated": "Availability updated successfully"
} }
} }
} }

View File

@@ -557,40 +557,277 @@
"save_success": "Información personal guardada exitosamente" "save_success": "Información personal guardada exitosamente"
}, },
"experience": { "experience": {
"title": "Experience & Skills", "title": "Experiencia y habilidades",
"industries_title": "Industries", "industries_title": "Industrias",
"industries_subtitle": "Select the industries you have experience in", "industries_subtitle": "Seleccione las industrias en las que tiene experiencia",
"skills_title": "Skills", "skills_title": "Habilidades",
"skills_subtitle": "Select your skills or add custom ones", "skills_subtitle": "Seleccione sus habilidades o añada personalizadas",
"custom_skills_title": "Custom Skills:", "custom_skills_title": "Habilidades personalizadas:",
"custom_skill_hint": "Add custom skill...", "custom_skill_hint": "Añadir habilidad...",
"save_button": "Save & Continue", "save_button": "Guardar y continuar",
"industries": { "industries": {
"hospitality": "Hospitality", "hospitality": "Hotelería",
"food_service": "Food Service", "food_service": "Servicio de alimentos",
"warehouse": "Warehouse", "warehouse": "Almacén",
"events": "Events", "events": "Eventos",
"retail": "Retail", "retail": "Venta al por menor",
"healthcare": "Healthcare", "healthcare": "Cuidado de la salud",
"other": "Other" "other": "Otro"
}, },
"skills": { "skills": {
"food_service": "Food Service", "food_service": "Servicio de alimentos",
"bartending": "Bartending", "bartending": "Bartending",
"event_setup": "Event Setup", "event_setup": "Montaje de eventos",
"hospitality": "Hospitality", "hospitality": "Hotelería",
"warehouse": "Warehouse", "warehouse": "Almacén",
"customer_service": "Customer Service", "customer_service": "Servicio al cliente",
"cleaning": "Cleaning", "cleaning": "Limpieza",
"security": "Security", "security": "Seguridad",
"retail": "Retail", "retail": "Venta al por menor",
"cooking": "Cooking", "cooking": "Cocinar",
"cashier": "Cashier", "cashier": "Cajero",
"server": "Server", "server": "Mesero",
"barista": "Barista", "barista": "Barista",
"host_hostess": "Host/Hostess", "host_hostess": "Anfitrión/Anfitriona",
"busser": "Busser", "busser": "Ayudante de mesero",
"driving": "Driving" "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", "title": "Turnos",
"tabs": { "tabs": {
"my_shifts": "Mis Turnos", "my_shifts": "Mis Turnos",
"find_work": "Buscar Trabajo" "find_work": "Buscar Trabajo",
"history": "Historial"
}, },
"list": { "list": {
"no_shifts": "No se encontraron turnos", "no_shifts": "No se encontraron turnos",
@@ -716,6 +954,40 @@
"tags": { "tags": {
"immediate_start": "Inicio inmediato", "immediate_start": "Inicio inmediato",
"no_experience": "Sin experiencia" "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": { "staff_time_card": {
@@ -744,6 +1016,7 @@
"sign_up_failed": "No pudimos crear tu cuenta. Por favor, intenta de nuevo.", "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.", "sign_in_failed": "No pudimos iniciar sesión. Por favor, intenta de nuevo.",
"not_authenticated": "Por favor, inicia sesión para continuar.", "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.", "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." "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!" "created": "¡Orden creada exitosamente!"
}, },
"profile": { "profile": {
"updated": "¡Perfil actualizado exitosamente!" "updated": "¡Perfil actualizado con éxito!"
},
"availability": {
"updated": "Disponibilidad actualizada con éxito"
} }
} }
} }

View File

@@ -24,11 +24,17 @@ mixin DataErrorHandler {
} on SocketException catch (e) { } on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}'); throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) { } 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( throw NetworkException(
technicalMessage: 'Firebase ${e.code}: ${e.message}'); technicalMessage: 'Firebase ${e.code}: ${e.message}');
} }
if (e.code == 'deadline-exceeded') { if (code == 'deadline-exceeded') {
throw ServiceUnavailableException( throw ServiceUnavailableException(
technicalMessage: 'Firebase ${e.code}: ${e.message}'); technicalMessage: 'Firebase ${e.code}: ${e.message}');
} }
@@ -36,8 +42,28 @@ mixin DataErrorHandler {
throw ServerException( throw ServerException(
technicalMessage: 'Firebase ${e.code}: ${e.message}'); technicalMessage: 'Firebase ${e.code}: ${e.message}');
} catch (e) { } 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 it's already an AppException, rethrow it
if (e is AppException) rethrow; if (e is AppException) rethrow;
// Debugging: Log unexpected errors
print('DataErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString()); throw UnknownException(technicalMessage: e.toString());
} }
} }

View File

@@ -29,6 +29,7 @@ class UiSnackbar {
required String message, required String message,
required UiSnackbarType type, required UiSnackbarType type,
Duration duration = const Duration(seconds: 3), Duration duration = const Duration(seconds: 3),
EdgeInsetsGeometry? margin,
}) { }) {
final Color textColor; final Color textColor;
final Color backgroundColor; final Color backgroundColor;
@@ -63,6 +64,7 @@ class UiSnackbar {
backgroundColor: UiColors.transparent, backgroundColor: UiColors.transparent,
elevation: 0, elevation: 0,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: margin ?? const EdgeInsets.all(16),
content: ClipRRect( content: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: BackdropFilter( child: BackdropFilter(

View File

@@ -12,7 +12,9 @@ import 'package:krow_domain/krow_domain.dart'
AccountExistsException, AccountExistsException,
UserNotFoundException, UserNotFoundException,
UnauthorizedAppException, UnauthorizedAppException,
PasswordMismatchException; UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/auth_repository_interface.dart'; import '../../domain/repositories/auth_repository_interface.dart';
@@ -63,6 +65,10 @@ class AuthRepositoryImpl
throw InvalidCredentialsException( throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}', technicalMessage: 'Firebase error code: ${e.code}',
); );
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
} else { } else {
throw SignInFailedException( throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}', technicalMessage: 'Firebase auth error: ${e.message}',
@@ -120,6 +126,10 @@ class AuthRepositoryImpl
password: password, password: password,
companyName: companyName, companyName: companyName,
); );
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
} else { } else {
throw SignUpFailedException( throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}', technicalMessage: 'Firebase auth error: ${e.message}',

View File

@@ -47,8 +47,11 @@ class ClientSignInPage extends StatelessWidget {
final String errorMessage = state.errorMessage != null final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown; : t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar(content: Text(errorMessage)), context,
message: errorMessage,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
); );
} }
}, },

View File

@@ -51,8 +51,11 @@ class ClientSignUpPage extends StatelessWidget {
final String errorMessage = state.errorMessage != null final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown; : t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar(content: Text(errorMessage)), context,
message: errorMessage,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
); );
} }
}, },

View File

@@ -47,9 +47,11 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
void _handleSubmit() { void _handleSubmit() {
if (_passwordController.text != _confirmPasswordController.text) { if (_passwordController.text != _confirmPasswordController.text) {
ScaffoldMessenger.of( UiSnackbar.show(
context, context,
).showSnackBar(const SnackBar(content: Text('Passwords do not match'))); message: translateErrorKey('passwords_dont_match'),
type: UiSnackbarType.error,
);
return; return;
} }

View File

@@ -71,13 +71,26 @@ class _BillingViewState extends State<BillingView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>( return BlocConsumer<BillingBloc, BillingState>(
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) { builder: (BuildContext context, BillingState state) {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: <Widget>[ slivers: <Widget>[
SliverAppBar( 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, pinned: true,
expandedHeight: 200.0, expandedHeight: 200.0,
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
@@ -180,10 +193,28 @@ class _BillingViewState extends State<BillingView> {
} }
if (state.status == BillingStatus.failure) { if (state.status == BillingStatus.failure) {
return Center( return Padding(
child: Text( padding: const EdgeInsets.all(UiConstants.space8),
state.errorMessage ?? 'An error occurred', child: Center(
style: UiTypography.body1r.textError, child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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<BillingBloc>(context).add(const BillingLoadStarted()),
),
],
),
), ),
); );
} }

View File

@@ -23,43 +23,56 @@ class CoverageRepositoryImpl implements CoverageRepository {
/// Fetches shifts for a specific date. /// Fetches shifts for a specific date.
@override @override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async { Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String? businessId = try {
dc.ClientSessionStore.instance.session?.business?.id; final String? businessId =
if (businessId == null || businessId.isEmpty) { dc.ClientSessionStore.instance.session?.business?.id;
return <CoverageShift>[]; if (businessId == null || businessId.isEmpty) {
return <CoverageShift>[];
}
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. /// Fetches coverage statistics for a specific date.
@@ -180,6 +193,7 @@ class CoverageRepositoryImpl implements CoverageRepository {
case dc.ApplicationStatus.REJECTED: case dc.ApplicationStatus.REJECTED:
return CoverageWorkerStatus.rejected; return CoverageWorkerStatus.rejected;
case dc.ApplicationStatus.CONFIRMED: case dc.ApplicationStatus.CONFIRMED:
case dc.ApplicationStatus.ACCEPTED:
return CoverageWorkerStatus.confirmed; return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.CHECKED_IN: case dc.ApplicationStatus.CHECKED_IN:
return CoverageWorkerStatus.checkedIn; return CoverageWorkerStatus.checkedIn;

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/coverage_bloc.dart'; import '../blocs/coverage_bloc.dart';
import '../blocs/coverage_event.dart'; import '../blocs/coverage_event.dart';
import '../blocs/coverage_state.dart'; import '../blocs/coverage_state.dart';
@@ -57,7 +58,16 @@ class _CoveragePageState extends State<CoveragePage> {
create: (BuildContext context) => Modular.get<CoverageBloc>() create: (BuildContext context) => Modular.get<CoverageBloc>()
..add(CoverageLoadRequested(date: DateTime.now())), ..add(CoverageLoadRequested(date: DateTime.now())),
child: Scaffold( child: Scaffold(
body: BlocBuilder<CoverageBloc, CoverageState>( body: BlocConsumer<CoverageBloc, CoverageState>(
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) { builder: (BuildContext context, CoverageState state) {
final DateTime selectedDate = state.selectedDate ?? DateTime.now(); final DateTime selectedDate = state.selectedDate ?? DateTime.now();
@@ -226,43 +236,45 @@ class _CoveragePageState extends State<CoveragePage> {
required BuildContext context, required BuildContext context,
required CoverageState state, required CoverageState state,
}) { }) {
if (state.status == CoverageStatus.loading) { if (state.shifts.isEmpty) {
return const Center( if (state.status == CoverageStatus.loading) {
child: CircularProgressIndicator(), return const Center(
); child: CircularProgressIndicator(),
} );
}
if (state.status == CoverageStatus.failure) { if (state.status == CoverageStatus.failure) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
const Icon( const Icon(
UiIcons.warning, UiIcons.error,
size: UiConstants.space12, size: 48,
color: UiColors.destructive, color: UiColors.error,
),
const SizedBox(height: UiConstants.space4),
Text(
'Failed to load coverage data',
style: UiTypography.title2m.copyWith(
color: UiColors.textPrimary,
), ),
), const SizedBox(height: UiConstants.space4),
const SizedBox(height: UiConstants.space2), Text(
Text( state.errorMessage != null
state.errorMessage ?? 'An unknown error occurred', ? translateErrorKey(state.errorMessage!)
style: UiTypography.body2r.copyWith( : 'An error occurred',
color: UiColors.mutedForeground, style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
), ),
textAlign: TextAlign.center, const SizedBox(height: UiConstants.space4),
), UiButton.secondary(
], text: 'Retry',
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(),
),
),
],
),
), ),
), );
); }
} }
return Padding( return Padding(

View File

@@ -10,7 +10,7 @@ import 'one_time_order_state.dart';
/// BLoC for managing the multi-step one-time order creation form. /// BLoC for managing the multi-step one-time order creation form.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState> class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with BlocErrorHandler<OneTimeOrderState> { with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect) OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect)
: super(OneTimeOrderState.initial()) { : super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded); on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);

View File

@@ -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 @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
date, date,

View File

@@ -25,7 +25,18 @@ class OneTimeOrderView extends StatelessWidget {
final TranslationsClientCreateOrderOneTimeEn labels = final TranslationsClientCreateOrderOneTimeEn labels =
t.client_create_order.one_time; t.client_create_order.one_time;
return BlocBuilder<OneTimeOrderBloc, OneTimeOrderState>( return BlocConsumer<OneTimeOrderBloc, OneTimeOrderState>(
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) { builder: (BuildContext context, OneTimeOrderState state) {
if (state.status == OneTimeOrderStatus.success) { if (state.status == OneTimeOrderStatus.success) {
return OneTimeOrderSuccessView( return OneTimeOrderSuccessView(
@@ -104,9 +115,11 @@ class OneTimeOrderView extends StatelessWidget {
? labels.creating ? labels.creating
: labels.create_order, : labels.create_order,
isLoading: state.status == OneTimeOrderStatus.loading, isLoading: state.status == OneTimeOrderStatus.loading,
onPressed: () => BlocProvider.of<OneTimeOrderBloc>( onPressed: state.isValid
context, ? () => BlocProvider.of<OneTimeOrderBloc>(
).add(const OneTimeOrderSubmitted()), context,
).add(const OneTimeOrderSubmitted())
: null,
), ),
], ],
), ),
@@ -286,7 +299,7 @@ class _BottomActionButton extends StatelessWidget {
this.isLoading = false, this.isLoading = false,
}); });
final String label; final String label;
final VoidCallback onPressed; final VoidCallback? onPressed;
final bool isLoading; final bool isLoading;
@override @override

View File

@@ -15,92 +15,103 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override @override
Future<HomeDashboardData> getDashboardData() async { Future<HomeDashboardData> getDashboardData() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; try {
if (businessId == null || businessId.isEmpty) { final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
return const HomeDashboardData( if (businessId == null || businessId.isEmpty) {
weeklySpending: 0, return const HomeDashboardData(
next7DaysSpending: 0, weeklySpending: 0,
weeklyShifts: 0, next7DaysSpending: 0,
next7DaysScheduled: 0, weeklyShifts: 0,
totalNeeded: 0, next7DaysScheduled: 0,
totalFilled: 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,
); );
} } catch (e) {
final String error = e.toString().toLowerCase();
final DateTime now = DateTime.now(); if (error.contains('network') ||
final int daysFromMonday = now.weekday - DateTime.monday; error.contains('connection') ||
final DateTime monday = error.contains('unavailable') ||
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); error.contains('offline') ||
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); error.contains('socket') ||
final DateTime weekRangeEnd = error.contains('failed host lookup')) {
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e');
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;
} }
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 @override
@@ -114,46 +125,57 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override @override
Future<List<ReorderItem>> getRecentReorders() async { Future<List<ReorderItem>> getRecentReorders() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; try {
if (businessId == null || businessId.isEmpty) { final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
return const <ReorderItem>[]; if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
}
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) { fdc.Timestamp _toTimestamp(DateTime date) {

View File

@@ -10,7 +10,7 @@ import 'client_home_state.dart';
/// BLoC responsible for managing the state and business logic of the client home dashboard. /// BLoC responsible for managing the state and business logic of the client home dashboard.
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
with BlocErrorHandler<ClientHomeState> { with BlocErrorHandler<ClientHomeState>, SafeBloc<ClientHomeEvent, ClientHomeState> {
ClientHomeBloc({ ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase, required GetDashboardDataUseCase getDashboardDataUseCase,
required GetRecentReordersUseCase getRecentReordersUseCase, required GetRecentReordersUseCase getRecentReordersUseCase,

View File

@@ -32,8 +32,21 @@ class ClientHomePage extends StatelessWidget {
ClientHomeHeader(i18n: i18n), ClientHomeHeader(i18n: i18n),
ClientHomeEditBanner(i18n: i18n), ClientHomeEditBanner(i18n: i18n),
Flexible( Flexible(
child: BlocBuilder<ClientHomeBloc, ClientHomeState>( child: BlocConsumer<ClientHomeBloc, ClientHomeState>(
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) { builder: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error) {
return _buildErrorState(context, state);
}
if (state.isEditMode) { if (state.isEditMode) {
return _buildEditModeList(context, state); 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: <Widget>[
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<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
} }

View File

@@ -34,18 +34,21 @@ class ClientHubsPage extends StatelessWidget {
}, },
listener: (BuildContext context, ClientHubsState state) { listener: (BuildContext context, ClientHubsState state) {
if (state.errorMessage != null && state.errorMessage!.isNotEmpty) { if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
final String errorMessage = translateErrorKey(state.errorMessage!); UiSnackbar.show(
ScaffoldMessenger.of(context).showSnackBar( context,
SnackBar(content: Text(errorMessage)), message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
); );
BlocProvider.of<ClientHubsBloc>( BlocProvider.of<ClientHubsBloc>(
context, context,
).add(const ClientHubsMessageCleared()); ).add(const ClientHubsMessageCleared());
} }
if (state.successMessage != null && state.successMessage!.isNotEmpty) { if (state.successMessage != null && state.successMessage!.isNotEmpty) {
ScaffoldMessenger.of( UiSnackbar.show(
context, context,
).showSnackBar(SnackBar(content: Text(state.successMessage!))); message: state.successMessage!,
type: UiSnackbarType.success,
);
BlocProvider.of<ClientHubsBloc>( BlocProvider.of<ClientHubsBloc>(
context, context,
).add(const ClientHubsMessageCleared()); ).add(const ClientHubsMessageCleared());

View File

@@ -52,6 +52,8 @@ class _AddHubDialogState extends State<AddHubDialog> {
super.dispose(); super.dispose();
} }
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -68,66 +70,85 @@ class _AddHubDialogState extends State<AddHubDialog> {
BoxShadow(color: UiColors.popupShadow, blurRadius: 20), BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
], ],
), ),
child: Column( child: Form(
mainAxisSize: MainAxisSize.min, key: _formKey,
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
Text( crossAxisAlignment: CrossAxisAlignment.stretch,
t.client_hubs.add_hub_dialog.title, children: <Widget>[
style: UiTypography.headline3m.textPrimary, Text(
), t.client_hubs.add_hub_dialog.title,
const SizedBox(height: UiConstants.space5), style: UiTypography.headline3m.textPrimary,
_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,
), ),
), const SizedBox(height: UiConstants.space5),
const SizedBox(height: UiConstants.space4), _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), TextFormField(
HubAddressAutocomplete( controller: _nameController,
controller: _addressController, style: UiTypography.body1r.textPrimary,
hintText: t.client_hubs.add_hub_dialog.address_hint, validator: (String? value) {
focusNode: _addressFocusNode, if (value == null || value.trim().isEmpty) {
onSelected: (Prediction prediction) { return 'Name is required';
_selectedPrediction = prediction; }
}, return null;
), },
const SizedBox(height: UiConstants.space8), decoration: _buildInputDecoration(
Row( t.client_hubs.add_hub_dialog.name_hint,
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
), ),
const SizedBox(width: UiConstants.space3), ),
Expanded( const SizedBox(height: UiConstants.space4),
child: UiButton.primary( _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
onPressed: () { // Assuming HubAddressAutocomplete is a custom widget wrapper.
if (_nameController.text.isNotEmpty) { // If it doesn't expose a validator, we might need to modify it or manually check _addressController.
widget.onCreate( // For now, let's just make sure we validate name. Address is tricky if it's a wrapper.
_nameController.text, HubAddressAutocomplete(
_addressController.text, controller: _addressController,
placeId: _selectedPrediction?.placeId, hintText: t.client_hubs.add_hub_dialog.address_hint,
latitude: double.tryParse( focusNode: _addressFocusNode,
_selectedPrediction?.lat ?? '', onSelected: (Prediction prediction) {
), _selectedPrediction = prediction;
longitude: double.tryParse( },
_selectedPrediction?.lng ?? '', ),
), const SizedBox(height: UiConstants.space8),
); Row(
} children: <Widget>[
}, Expanded(
text: t.client_hubs.add_hub_dialog.create_button, 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,
),
),
],
),
],
),
), ),
), ),
), ),

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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 '../blocs/client_settings_bloc.dart';
import '../widgets/client_settings_page/settings_actions.dart'; import '../widgets/client_settings_page/settings_actions.dart';
@@ -24,15 +26,19 @@ class ClientSettingsPage extends StatelessWidget {
child: BlocListener<ClientSettingsBloc, ClientSettingsState>( child: BlocListener<ClientSettingsBloc, ClientSettingsState>(
listener: (BuildContext context, ClientSettingsState state) { listener: (BuildContext context, ClientSettingsState state) {
if (state is ClientSettingsSignOutSuccess) { if (state is ClientSettingsSignOutSuccess) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
const SnackBar(content: Text('Signed out successfully')), context,
message: 'Signed out successfully',
type: UiSnackbarType.success,
); );
Modular.to.toClientRoot(); Modular.to.toClientRoot();
} }
if (state is ClientSettingsError) { if (state is ClientSettingsError) {
ScaffoldMessenger.of( UiSnackbar.show(
context, context,
).showSnackBar(SnackBar(content: Text(state.message))); message: translateErrorKey(state.message),
type: UiSnackbarType.error,
);
} }
}, },
child: const Scaffold( child: const Scaffold(

View File

@@ -62,7 +62,10 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
); );
_updateDerivedState(); _updateDerivedState();
}, },
onError: (String _) => state.copyWith(status: ViewOrdersStatus.failure), onError: (String message) => state.copyWith(
status: ViewOrdersStatus.failure,
errorMessage: message,
),
); );
} }

View File

@@ -15,9 +15,11 @@ class ViewOrdersState extends Equatable {
this.activeCount = 0, this.activeCount = 0,
this.completedCount = 0, this.completedCount = 0,
this.upNextCount = 0, this.upNextCount = 0,
this.errorMessage,
}); });
final ViewOrdersStatus status; final ViewOrdersStatus status;
final String? errorMessage;
final List<OrderItem> orders; final List<OrderItem> orders;
final List<OrderItem> filteredOrders; final List<OrderItem> filteredOrders;
final List<DateTime> calendarDays; final List<DateTime> calendarDays;
@@ -39,9 +41,11 @@ class ViewOrdersState extends Equatable {
int? activeCount, int? activeCount,
int? completedCount, int? completedCount,
int? upNextCount, int? upNextCount,
String? errorMessage,
}) { }) {
return ViewOrdersState( return ViewOrdersState(
status: status ?? this.status, status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
orders: orders ?? this.orders, orders: orders ?? this.orders,
filteredOrders: filteredOrders ?? this.filteredOrders, filteredOrders: filteredOrders ?? this.filteredOrders,
calendarDays: calendarDays ?? this.calendarDays, calendarDays: calendarDays ?? this.calendarDays,
@@ -66,5 +70,6 @@ class ViewOrdersState extends Equatable {
activeCount, activeCount,
completedCount, completedCount,
upNextCount, upNextCount,
errorMessage,
]; ];
} }

View File

@@ -68,7 +68,17 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>( return BlocConsumer<ViewOrdersCubit, ViewOrdersState>(
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) { builder: (BuildContext context, ViewOrdersState state) {
final List<DateTime> calendarDays = state.calendarDays; final List<DateTime> calendarDays = state.calendarDays;
final List<OrderItem> filteredOrders = state.filteredOrders; final List<OrderItem> filteredOrders = state.filteredOrders;
@@ -101,64 +111,66 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
// Content List // Content List
Expanded( Expanded(
child: filteredOrders.isEmpty child: state.status == ViewOrdersStatus.failure
? _buildEmptyState(context: context, state: state) ? _buildErrorState(context: context, state: state)
: ListView( : filteredOrders.isEmpty
padding: const EdgeInsets.fromLTRB( ? _buildEmptyState(context: context, state: state)
UiConstants.space5, : ListView(
UiConstants.space4, padding: const EdgeInsets.fromLTRB(
UiConstants.space5, UiConstants.space5,
100, UiConstants.space4,
), UiConstants.space5,
children: <Widget>[ 100,
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: Row(
children: <Widget>[
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( children: <Widget>[
(OrderItem order) => Padding( if (filteredOrders.isNotEmpty)
padding: const EdgeInsets.only( Padding(
bottom: UiConstants.space3, padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: Row(
children: <Widget>[
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<ViewOrdersView> {
if (checkDate == tomorrow) return 'Tomorrow'; if (checkDate == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date); return DateFormat('EEE, MMM d').format(date);
} }
Widget _buildErrorState({
required BuildContext context,
required ViewOrdersState state,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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<ViewOrdersCubit>(context)
.jumpToDate(state.selectedDate ?? DateTime.now()),
),
],
),
);
}
} }

View File

@@ -60,9 +60,17 @@ class AuthRepositoryImpl
}, },
verificationFailed: (FirebaseAuthException e) { verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) { if (!completer.isCompleted) {
completer.completeError( // Map Firebase network errors to NetworkException
Exception(e.message ?? 'Phone verification failed.'), 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, _) { codeSent: (String verificationId, _) {
@@ -107,10 +115,25 @@ class AuthRepositoryImpl
verificationId: verificationId, verificationId: verificationId,
smsCode: smsCode, 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; final User? firebaseUser = userCredential.user;
if (firebaseUser == null) { 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<GetUserByIdData, GetUserByIdVariables> response = final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
@@ -135,7 +158,9 @@ class AuthRepositoryImpl
} else { } else {
if (user.userRole != 'STAFF') { if (user.userRole != 'STAFF') {
await firebaseAuth.signOut(); 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<GetStaffByUserIdData, GetStaffByUserIdVariables> final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await executeProtected(() => dataConnect staffResponse = await executeProtected(() => dataConnect
@@ -145,19 +170,23 @@ class AuthRepositoryImpl
.execute()); .execute());
if (staffResponse.data.staffs.isNotEmpty) { if (staffResponse.data.staffs.isNotEmpty) {
await firebaseAuth.signOut(); await firebaseAuth.signOut();
throw Exception( throw const domain.AccountExistsException(
'This user already has a staff profile. Please log in.', technicalMessage: 'This user already has a staff profile. Please log in.',
); );
} }
} }
} else { } else {
if (user == null) { if (user == null) {
await firebaseAuth.signOut(); 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') { if (user.userRole != 'STAFF') {
await firebaseAuth.signOut(); 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<GetStaffByUserIdData, GetStaffByUserIdVariables> final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
@@ -168,8 +197,8 @@ class AuthRepositoryImpl
.execute()); .execute());
if (staffResponse.data.staffs.isEmpty) { if (staffResponse.data.staffs.isEmpty) {
await firebaseAuth.signOut(); await firebaseAuth.signOut();
throw Exception( throw const domain.UserNotFoundException(
'Your account is not registered yet. Please register first.', technicalMessage: 'Your account is not registered yet. Please register first.',
); );
} }
staffRecord = staffResponse.data.staffs.first; staffRecord = staffResponse.data.staffs.first;

View File

@@ -39,7 +39,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
@override @override
void dispose() { void dispose() {
_authBloc.add(AuthResetRequested(mode: widget.mode)); if (!_authBloc.isClosed) {
_authBloc.add(AuthResetRequested(mode: widget.mode));
}
super.dispose(); super.dispose();
} }
@@ -50,16 +52,17 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
}) { }) {
final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), ''); final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), '');
if (normalized.length == 10) { if (normalized.length == 10) {
BlocProvider.of<AuthBloc>(context).add( BlocProvider.of<AuthBloc>(
context,
).add(
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text( message: t.staff_authentication.phone_verification_page.validation_error,
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<PhoneVerificationPage> {
required String otp, required String otp,
required String verificationId, required String verificationId,
}) { }) {
BlocProvider.of<AuthBloc>(context).add( BlocProvider.of<AuthBloc>(
context,
).add(
AuthOtpSubmitted( AuthOtpSubmitted(
verificationId: verificationId, verificationId: verificationId,
smsCode: otp, smsCode: otp,
@@ -81,9 +86,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
/// Handles the request to resend the verification code using the phone number in the state. /// Handles the request to resend the verification code using the phone number in the state.
void _onResend({required BuildContext context}) { void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>( BlocProvider.of<AuthBloc>(context).add(
context, AuthSignInRequested(mode: widget.mode),
).add(AuthSignInRequested(mode: widget.mode)); );
} }
@override @override
@@ -102,23 +107,20 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
} }
} else if (state.status == AuthStatus.error && } else if (state.status == AuthStatus.error &&
state.mode == AuthMode.signup) { state.mode == AuthMode.signup) {
final String message = state.errorMessage ?? ''; final String messageKey = state.errorMessage ?? '';
if (message.contains('staff profile')) { // Handle specific business logic errors for signup
final ScaffoldMessengerState messenger = ScaffoldMessenger.of( if (messageKey == 'errors.auth.account_exists') {
UiSnackbar.show(
context, context,
); message: translateErrorKey(messageKey),
messenger.hideCurrentSnackBar(); type: UiSnackbarType.error,
messenger.showSnackBar( margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
SnackBar(
content: Text(message),
duration: const Duration(seconds: 5),
),
); );
Future<void>.delayed(const Duration(seconds: 5), () { Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return; if (!mounted) return;
Modular.to.navigate('/'); Modular.to.navigate('/');
}); });
} else if (message.contains('not authorized')) { } else if (messageKey == 'errors.auth.unauthorized_app') {
Modular.to.pop(); Modular.to.pop();
} }
} }
@@ -145,9 +147,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
centerTitle: true, centerTitle: true,
showBackButton: true, showBackButton: true,
onLeadingPressed: () { onLeadingPressed: () {
BlocProvider.of<AuthBloc>( BlocProvider.of<AuthBloc>(context).add(
context, AuthResetRequested(mode: widget.mode),
).add(AuthResetRequested(mode: widget.mode)); );
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@@ -167,13 +169,13 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
verificationId: state.verificationId ?? '', verificationId: state.verificationId ?? '',
), ),
) )
: PhoneInput( : PhoneInput(
state: state, state: state,
onSendCode: (String phoneNumber) => _onSendCode( onSendCode: (String phoneNumber) => _onSendCode(
context: context, context: context,
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
),
), ),
),
), ),
), ),
); );

View File

@@ -96,13 +96,11 @@ class _ProfileSetupPageState extends State<ProfileSetupPage> {
if (state.status == ProfileSetupStatus.success) { if (state.status == ProfileSetupStatus.success) {
Modular.to.toStaffHome(); Modular.to.toStaffHome();
} else if (state.status == ProfileSetupStatus.failure) { } else if (state.status == ProfileSetupStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text( message: translateErrorKey(state.errorMessage ?? t.staff_authentication.profile_setup_page.error_occurred),
state.errorMessage ?? type: UiSnackbarType.error,
t.staff_authentication.profile_setup_page.error_occurred, margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16),
),
),
); );
} }
}, },

View File

@@ -1,71 +1,119 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.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}); const GetStartedBackground({super.key});
@override
State<GetStartedBackground> createState() => _GetStartedBackgroundState();
}
class _GetStartedBackgroundState extends State<GetStartedBackground> {
bool _hasError = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Container(
children: [ child: Column(
const SizedBox(height: UiConstants.space8), children: [
// Logo const SizedBox(height: 32),
Image.asset(UiImageAssets.logoBlue, height: 40), // Logo
Expanded( Image.asset(
child: Center( UiImageAssets.logoBlue,
child: Container( height: 40,
width: 288, ),
height: 288, Expanded(
decoration: BoxDecoration( child: Center(
shape: BoxShape.circle, child: Container(
color: UiColors.bgSecondary.withValues(alpha: 0.5), width: 288,
), height: 288,
padding: const EdgeInsets.all(UiConstants.space2), decoration: BoxDecoration(
child: ClipOval( shape: BoxShape.circle,
child: Image.network( color: const Color(0xFF3A4A5A).withOpacity(0.05),
'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces', ),
fit: BoxFit.cover, padding: const EdgeInsets.all(8.0),
errorBuilder: (context, error, stackTrace) { child: ClipOval(
return Image.asset(UiImageAssets.logoBlue); 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)
// Pagination dots (Visual only) Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Container(
Container( width: 24,
width: 24, height: 8,
height: 8, decoration: BoxDecoration(
decoration: BoxDecoration( color: UiColors.primary,
color: UiColors.primary, borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(4), ),
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), Container(
Container( width: 8,
width: 8, height: 8,
height: 8, decoration: BoxDecoration(
decoration: BoxDecoration( color: UiColors.primary.withOpacity(0.2),
color: UiColors.primary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(4), ),
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), Container(
Container( width: 8,
width: 8, height: 8,
height: 8, decoration: BoxDecoration(
decoration: BoxDecoration( color: UiColors.primary.withOpacity(0.2),
color: UiColors.primary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(4), ),
), ),
), ],
], ),
), ],
], ),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:core_localization/core_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/auth_event.dart'; import '../../../blocs/auth_event.dart';
import '../../../blocs/auth_bloc.dart'; import '../../../blocs/auth_bloc.dart';
@@ -118,7 +119,10 @@ class _OtpInputFieldState extends State<OtpInputField> {
Padding( Padding(
padding: const EdgeInsets.only(top: UiConstants.space4), padding: const EdgeInsets.only(top: UiConstants.space4),
child: Center( child: Center(
child: Text(widget.error, style: UiTypography.body2r.textError), child: Text(
translateErrorKey(widget.error),
style: UiTypography.body2r.textError,
),
), ),
), ),
], ],

View File

@@ -1,7 +1,8 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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. /// A widget that displays the phone number input field with country code.
/// ///
@@ -100,7 +101,10 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
if (widget.error.isNotEmpty) if (widget.error.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: UiConstants.space2), padding: const EdgeInsets.only(top: UiConstants.space2),
child: Text(widget.error, style: UiTypography.body2r.textError), child: Text(
translateErrorKey(widget.error),
style: UiTypography.body2r.textError,
),
), ),
], ],
); );

View File

@@ -9,9 +9,12 @@ import '../../domain/repositories/availability_repository.dart';
/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek), /// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek),
/// not specific date availability. Therefore, updating availability for a specific /// not specific date availability. Therefore, updating availability for a specific
/// date will update the availability for that Day of Week globally (Recurring). /// 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 dc.ExampleConnector _dataConnect;
final firebase.FirebaseAuth _firebaseAuth; final firebase.FirebaseAuth _firebaseAuth;
String? _cachedStaffId;
AvailabilityRepositoryImpl({ AvailabilityRepositoryImpl({
required dc.ExampleConnector dataConnect, required dc.ExampleConnector dataConnect,
@@ -20,85 +23,93 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository {
_firebaseAuth = firebaseAuth; _firebaseAuth = firebaseAuth;
Future<String> _getStaffId() async { Future<String> _getStaffId() async {
if (_cachedStaffId != null) return _cachedStaffId!;
final firebase.User? user = _firebaseAuth.currentUser; 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<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result = final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute(); await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) { 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 @override
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end) async { Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end) async {
final String staffId = await _getStaffId(); return executeProtected(() async {
final String staffId = await _getStaffId();
// 1. Fetch Weekly recurring availability // 1. Fetch Weekly recurring availability
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result = final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities; final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable> // 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {}; final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
for (final item in items) { for (final item in items) {
dc.DayOfWeek day; dc.DayOfWeek day;
try { try {
day = dc.DayOfWeek.values.byName(item.day.stringValue); day = dc.DayOfWeek.values.byName(item.day.stringValue);
} catch (_) { } catch (_) {
continue; 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;
} }
dc.AvailabilitySlot slot; // 3. Generate DayAvailability for requested range
try { final List<DayAvailability> days = [];
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue); final int dayCount = end.difference(start).inDays;
} catch (_) {
continue; for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
// We define 3 standard slots for every day
final List<AvailabilitySlot> 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;
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<DayAvailability> 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<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
// We define 3 standard slots for every day
final List<AvailabilitySlot> 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( AvailabilitySlot _createSlot(
@@ -113,65 +124,68 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository {
@override @override
Future<DayAvailability> updateDayAvailability(DayAvailability availability) async { Future<DayAvailability> updateDayAvailability(DayAvailability availability) async {
final String staffId = await _getStaffId(); return executeProtected(() async {
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday); final String staffId = await _getStaffId();
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
// Update each slot in the backend. // Update each slot in the backend.
// This updates the recurring rule for this DayOfWeek. // This updates the recurring rule for this DayOfWeek.
for (final AvailabilitySlot slot in availability.slots) { for (final AvailabilitySlot slot in availability.slots) {
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id); final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable); final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
await _upsertSlot(staffId, dow, slotEnum, status); await _upsertSlot(staffId, dow, slotEnum, status);
} }
return availability; return availability;
});
} }
@override @override
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async { Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async {
final String staffId = await _getStaffId(); return executeProtected(() async {
final String staffId = await _getStaffId();
// QuickSet updates the Recurring schedule for all days involved. // 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. // 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 int dayCount = end.difference(start).inDays;
final Set<dc.DayOfWeek> processedDays = {}; final Set<dc.DayOfWeek> processedDays = {};
final List<DayAvailability> resultDays = []; final List<DayAvailability> resultDays = [];
for (int i = 0; i <= dayCount; i++) { final List<Future<void>> futures = [];
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i)); final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday); final dc.DayOfWeek dow = _toBackendDay(date.weekday);
// Logic to determine if enabled based on type // Logic to determine if enabled based on type
bool enableDay = false; bool enableDay = false;
if (type == 'all') enableDay = true; if (type == 'all') {
else if (type == 'clear') enableDay = false; enableDay = true;
else if (type == 'weekdays') { } else if (type == 'clear') {
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY); enableDay = false;
} else if (type == 'weekdays') {
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
} else if (type == 'weekends') { } 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) // Only update backend once per DayOfWeek (since it's recurring)
// to avoid redundant calls if range > 1 week.
if (!processedDays.contains(dow)) { if (!processedDays.contains(dow)) {
processedDays.add(dow); processedDays.add(dow);
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
final dc.AvailabilityStatus status = _boolToStatus(enableDay); futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status));
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status));
await Future.wait([ futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status));
_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status),
]);
} }
// Prepare return object // Prepare return object
final slots = [ final slots = [
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay), AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay), AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay), AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
]; ];
resultDays.add(DayAvailability( resultDays.add(DayAvailability(
@@ -179,9 +193,13 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository {
isAvailable: enableDay, isAvailable: enableDay,
slots: slots, slots: slots,
)); ));
} }
return resultDays; // Execute all updates in parallel
await Future.wait(futures);
return resultDays;
});
} }
Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async { Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async {

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -41,11 +42,12 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.availability;
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: Scaffold( child: Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
title: 'My Availability', title: i18n.title,
centerTitle: false, centerTitle: false,
showBackButton: true, showBackButton: true,
), ),
@@ -62,7 +64,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
if (state is AvailabilityError) { if (state is AvailabilityError) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: state.message, message: translateErrorKey(state.message),
type: UiSnackbarType.error, type: UiSnackbarType.error,
); );
} }
@@ -100,14 +102,28 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
), ),
), ),
if (state.isActionInProgress) 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) { } else if (state is AvailabilityError) {
return Center( return Center(
child: Text( child: Padding(
'Error: ${state.message}', padding: const EdgeInsets.all(24.0),
style: UiTypography.body2r.textError, 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<AvailabilityPage> {
} }
Widget _buildQuickSet(BuildContext context) { Widget _buildQuickSet(BuildContext context) {
final i18n = Translations.of(context).staff.availability;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -130,26 +147,28 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Quick Set Availability', i18n.quick_set_title,
style: UiTypography.body2b, style: UiTypography.body2b,
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Row( Row(
children: [ children: [
Expanded(child: _buildQuickSetButton(context, 'All Week', 'all')),
const SizedBox(width: UiConstants.space2),
Expanded( Expanded(
child: _buildQuickSetButton(context, 'Weekdays', 'weekdays'), child: _buildQuickSetButton(context, i18n.all_week, 'all'),
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
Expanded( 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), const SizedBox(width: UiConstants.space2),
Expanded( Expanded(
child: _buildQuickSetButton( child: _buildQuickSetButton(
context, context,
'Clear All', i18n.clear_all,
'clear', 'clear',
isDestructive: true, isDestructive: true,
), ),
@@ -368,7 +387,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
style: UiTypography.title2b, style: UiTypography.title2b,
), ),
Text( 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, style: UiTypography.body2r.textSecondary,
), ),
], ],
@@ -540,6 +567,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
} }
Widget _buildInfoCard() { Widget _buildInfoCard() {
final i18n = Translations.of(context).staff.availability;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -557,11 +585,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
spacing: UiConstants.space1, spacing: UiConstants.space1,
children: [ children: [
Text( Text(
'Auto-Match uses your availability', i18n.auto_match_title,
style: UiTypography.body2m, style: UiTypography.body2m,
), ),
Text( Text(
"When enabled, you'll only be matched with shifts during your available times.", i18n.auto_match_description,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
], ],

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -31,6 +32,7 @@ class _ClockInPageState extends State<ClockInPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.clock_in;
return BlocProvider<ClockInBloc>.value( return BlocProvider<ClockInBloc>.value(
value: _bloc, value: _bloc,
child: BlocConsumer<ClockInBloc, ClockInState>( child: BlocConsumer<ClockInBloc, ClockInState>(
@@ -39,7 +41,7 @@ class _ClockInPageState extends State<ClockInPage> {
state.errorMessage != null) { state.errorMessage != null) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: state.errorMessage!, message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error, type: UiSnackbarType.error,
); );
} }
@@ -67,7 +69,7 @@ class _ClockInPageState extends State<ClockInPage> {
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
titleWidget: Text( titleWidget: Text(
'Clock In to your Shift', i18n.title,
style: UiTypography.title1m.textPrimary, style: UiTypography.title1m.textPrimary,
), ),
showBackButton: false, showBackButton: false,
@@ -114,7 +116,7 @@ class _ClockInPageState extends State<ClockInPage> {
// Your Activity Header // Your Activity Header
Text( Text(
"Your Activity", i18n.your_activity,
textAlign: TextAlign.start, textAlign: TextAlign.start,
style: UiTypography.headline4m, style: UiTypography.headline4m,
), ),
@@ -160,21 +162,23 @@ class _ClockInPageState extends State<ClockInPage> {
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
shift.id == shift.id ==
selectedShift?.id
? "SELECTED SHIFT"
: "TODAY'S SHIFT",
style: UiTypography
.titleUppercase4b
.copyWith(
color: shift.id ==
selectedShift?.id selectedShift?.id
? UiColors.primary ? i18n
: UiColors .selected_shift_badge
.textSecondary, : i18n
.today_shift_badge,
style: UiTypography
.titleUppercase4b
.copyWith(
color: shift.id ==
selectedShift?.id
? UiColors.primary
: UiColors
.textSecondary,
),
), ),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
shift.title, shift.title,
@@ -236,12 +240,16 @@ class _ClockInPageState extends State<ClockInPage> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
"You're early!", i18n.early_title,
style: UiTypography.body1m.textSecondary, style: UiTypography.body1m.textSecondary,
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
"Check-in available at ${_getCheckInAvailabilityTime(selectedShift)}", i18n.check_in_at(
time: _getCheckInAvailabilityTime(
selectedShift,
),
),
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -315,12 +323,12 @@ class _ClockInPageState extends State<ClockInPage> {
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Text( Text(
"Shift Completed!", i18n.shift_completed,
style: UiTypography.body1b.textSuccess, style: UiTypography.body1b.textSuccess,
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
"Great work today", i18n.great_work,
style: UiTypography.body2r.textSuccess, style: UiTypography.body2r.textSuccess,
), ),
], ],
@@ -338,13 +346,13 @@ class _ClockInPageState extends State<ClockInPage> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Text( Text(
"No confirmed shifts for today", i18n.no_shifts_today,
style: UiTypography.body1m.textSecondary, style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
"Accept a shift to clock in", i18n.accept_shift_cta,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -376,7 +384,7 @@ class _ClockInPageState extends State<ClockInPage> {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
"Checked in at", i18n.checked_in_at_label,
style: UiTypography.body3m.textSuccess, style: UiTypography.body3m.textSuccess,
), ),
Text( Text(
@@ -471,6 +479,7 @@ class _ClockInPageState extends State<ClockInPage> {
} }
Future<void> _showNFCDialog(BuildContext context) async { Future<void> _showNFCDialog(BuildContext context) async {
final i18n = Translations.of(context).staff.clock_in;
bool scanned = false; bool scanned = false;
// Using a local navigator context since we are in a dialog // Using a local navigator context since we are in a dialog
@@ -481,7 +490,11 @@ class _ClockInPageState extends State<ClockInPage> {
return StatefulBuilder( return StatefulBuilder(
builder: (BuildContext context, setState) { builder: (BuildContext context, setState) {
return AlertDialog( 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( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
@@ -502,14 +515,16 @@ class _ClockInPageState extends State<ClockInPage> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
scanned ? 'Processing check-in...' : 'Ready to scan', scanned
? i18n.nfc_dialog.processing
: i18n.nfc_dialog.ready_to_scan,
style: UiTypography.headline4m, style: UiTypography.headline4m,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
scanned scanned
? 'Please wait...' ? i18n.nfc_dialog.please_wait
: 'Hold your phone near the NFC tag at the clock-in station', : i18n.nfc_dialog.scan_instruction,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
@@ -537,7 +552,7 @@ class _ClockInPageState extends State<ClockInPage> {
}, },
icon: const Icon(UiIcons.nfc, size: 24), icon: const Icon(UiIcons.nfc, size: 24),
label: Text( label: Text(
'Tap to Scan', i18n.nfc_dialog.tap_to_scan,
style: UiTypography.headline4m.white, style: UiTypography.headline4m.white,
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -607,7 +622,8 @@ class _ClockInPageState extends State<ClockInPage> {
final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15)); final DateTime windowStart = shiftStart.subtract(const Duration(minutes: 15));
return DateFormat('h:mm a').format(windowStart); return DateFormat('h:mm a').format(windowStart);
} catch (e) { } catch (e) {
return 'soon'; final i18n = Translations.of(context).staff.clock_in;
return i18n.soon;
} }
} }
} }

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -131,6 +132,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final CommuteMode mode = _getAppMode(); final CommuteMode mode = _getAppMode();
final i18n = Translations.of(context).staff.clock_in.commute;
// Notify parent of mode change // Notify parent of mode change
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -142,20 +144,20 @@ class _CommuteTrackerState extends State<CommuteTracker> {
return const SizedBox.shrink(); return const SizedBox.shrink();
case CommuteMode.needsConsent: case CommuteMode.needsConsent:
return _buildConsentCard(); return _buildConsentCard(i18n);
case CommuteMode.preShiftCommuteAllowed: case CommuteMode.preShiftCommuteAllowed:
return _buildPreShiftCard(); return _buildPreShiftCard(i18n);
case CommuteMode.commuteModeActive: case CommuteMode.commuteModeActive:
return _buildActiveCommuteScreen(); return _buildActiveCommuteScreen(i18n);
case CommuteMode.arrivedCanClockIn: case CommuteMode.arrivedCanClockIn:
return _buildArrivedCard(); return _buildArrivedCard(i18n);
} }
} }
Widget _buildConsentCard() { Widget _buildConsentCard(TranslationsStaffClockInCommuteEn i18n) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space5), margin: const EdgeInsets.only(bottom: UiConstants.space5),
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3),
@@ -202,12 +204,12 @@ class _CommuteTrackerState extends State<CommuteTracker> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Enable Commute Tracking?', i18n.enable_title,
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
'Share location 1hr before shift so your manager can see you\'re on the way.', i18n.enable_desc,
style: UiTypography.body4r.textSecondary, style: UiTypography.body4r.textSecondary,
), ),
], ],
@@ -229,7 +231,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
side: const BorderSide(color: UiColors.border), 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), const SizedBox(width: UiConstants.space2),
@@ -245,7 +247,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
), ),
child: Text( child: Text(
'Enable', i18n.enable,
style: UiTypography.footnote1m.white, style: UiTypography.footnote1m.white,
), ),
), ),
@@ -257,7 +259,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
); );
} }
Widget _buildPreShiftCard() { Widget _buildPreShiftCard(TranslationsStaffClockInCommuteEn i18n) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space5), margin: const EdgeInsets.only(bottom: UiConstants.space5),
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3),
@@ -295,7 +297,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
Row( Row(
children: <Widget>[ children: <Widget>[
Text( Text(
'On My Way', i18n.on_my_way,
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
@@ -308,7 +310,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
'Shift starts in ${_getMinutesUntilShift()} min', i18n.starts_in(min: _getMinutesUntilShift().toString()),
style: UiTypography.titleUppercase4m.textSecondary, style: UiTypography.titleUppercase4m.textSecondary,
), ),
], ],
@@ -316,7 +318,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
], ],
), ),
Text( Text(
'Track arrival', i18n.track_arrival,
style: UiTypography.titleUppercase4m.textSecondary, style: UiTypography.titleUppercase4m.textSecondary,
), ),
], ],
@@ -335,7 +337,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
); );
} }
Widget _buildActiveCommuteScreen() { Widget _buildActiveCommuteScreen(TranslationsStaffClockInCommuteEn i18n) {
return Container( return Container(
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -353,11 +355,11 @@ class _CommuteTrackerState extends State<CommuteTracker> {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
TweenAnimationBuilder( TweenAnimationBuilder(
tween: Tween<double>(begin: 1.0, end: 1.1), tween: Tween<double>(begin: 1.0, end: 1.1),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
@@ -387,12 +389,12 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
'On My Way', i18n.on_my_way,
style: UiTypography.displayMb.white, style: UiTypography.displayMb.white,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'Your manager can see you\'re heading to the site', i18n.heading_to_site,
style: UiTypography.body2r.copyWith( style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.8), color: UiColors.primaryForeground.withValues(alpha: 0.8),
), ),
@@ -414,7 +416,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Text( Text(
'Distance to Site', i18n.distance_to_site,
style: UiTypography.body2r.copyWith( style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.8), color: UiColors.primaryForeground.withValues(alpha: 0.8),
), ),
@@ -443,14 +445,14 @@ class _CommuteTrackerState extends State<CommuteTracker> {
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
Text( Text(
'Estimated Arrival', i18n.estimated_arrival,
style: UiTypography.body2r.copyWith( style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.8), color: UiColors.primaryForeground.withValues(alpha: 0.8),
), ),
), ),
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
'${widget.etaMinutes} min', i18n.eta_label(min: widget.etaMinutes.toString()),
style: UiTypography.headline1m.white, style: UiTypography.headline1m.white,
), ),
], ],
@@ -460,7 +462,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
], ],
const SizedBox(height: UiConstants.space8), const SizedBox(height: UiConstants.space8),
Text( 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( style: UiTypography.footnote1r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.8), color: UiColors.primaryForeground.withValues(alpha: 0.8),
), ),
@@ -485,7 +487,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
minimumSize: const Size(double.infinity, 48), 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<CommuteTracker> {
); );
} }
Widget _buildArrivedCard() { Widget _buildArrivedCard(TranslationsStaffClockInCommuteEn i18n) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space5), margin: const EdgeInsets.only(bottom: UiConstants.space5),
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
@@ -533,12 +535,12 @@ class _CommuteTrackerState extends State<CommuteTracker> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'You\'ve Arrived! 🎉', i18n.arrived_title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'You\'re at the shift location. Ready to clock in?', i18n.arrived_desc,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -22,13 +23,6 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
String _additionalNotes = ''; String _additionalNotes = '';
final List<String> _timeOptions = _generateTimeOptions(); final List<String> _timeOptions = _generateTimeOptions();
final List<String> _noLunchReasons = <String>[
'Unpredictable Workflows',
'Poor Time Management',
'Lack of coverage or short Staff',
'No Lunch Area',
'Other (Please specify)',
];
static List<String> _generateTimeOptions() { static List<String> _generateTimeOptions() {
final List<String> options = <String>[]; final List<String> options = <String>[];
@@ -45,6 +39,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.clock_in.lunch_break;
return Dialog( return Dialog(
backgroundColor: UiColors.white, backgroundColor: UiColors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -52,29 +47,29 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
), ),
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: _buildCurrentStep(), child: _buildCurrentStep(i18n),
), ),
); );
} }
Widget _buildCurrentStep() { Widget _buildCurrentStep(TranslationsStaffClockInLunchBreakEn i18n) {
switch (_step) { switch (_step) {
case 1: case 1:
return _buildStep1(); return _buildStep1(i18n);
case 2: case 2:
return _buildStep2(); return _buildStep2(i18n);
case 102: // 2b: No lunch reason case 102: // 2b: No lunch reason
return _buildStep2b(); return _buildStep2b(i18n);
case 3: case 3:
return _buildStep3(); return _buildStep3(i18n);
case 4: case 4:
return _buildStep4(); return _buildStep4(i18n);
default: default:
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
} }
Widget _buildStep1() { Widget _buildStep1(TranslationsStaffClockInLunchBreakEn i18n) {
return Padding( return Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
child: Column( child: Column(
@@ -95,7 +90,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
"Did You Take\na Lunch?", i18n.title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.headline1m.textPrimary, style: UiTypography.headline1m.textPrimary,
), ),
@@ -121,7 +116,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
"No", i18n.no,
style: UiTypography.body1m.textPrimary, style: UiTypography.body1m.textPrimary,
), ),
), ),
@@ -146,7 +141,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
), ),
), ),
child: Text( child: Text(
"Yes", i18n.yes,
style: UiTypography.body1m.white, style: UiTypography.body1m.white,
), ),
), ),
@@ -158,7 +153,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
); );
} }
Widget _buildStep2() { Widget _buildStep2(TranslationsStaffClockInLunchBreakEn i18n) {
// Time input // Time input
return Padding( return Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
@@ -166,7 +161,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Text( Text(
"When did you take lunch?", i18n.when_title,
style: UiTypography.headline4m, style: UiTypography.headline4m,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
@@ -186,9 +181,9 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
) )
.toList(), .toList(),
onChanged: (String? v) => setState(() => _breakStart = v), onChanged: (String? v) => setState(() => _breakStart = v),
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Start', labelText: i18n.start,
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 8, vertical: 8,
), ),
@@ -209,9 +204,9 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
) )
.toList(), .toList(),
onChanged: (String? v) => setState(() => _breakEnd = v), onChanged: (String? v) => setState(() => _breakEnd = v),
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'End', labelText: i18n.end,
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 8, vertical: 8,
), ),
@@ -230,14 +225,14 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), 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 // No lunch reason
return Padding( return Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
@@ -246,11 +241,11 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
Text( Text(
"Why didn't you take lunch?", i18n.why_no_lunch,
style: UiTypography.headline4m, style: UiTypography.headline4m,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
..._noLunchReasons.map( ...i18n.reasons.map(
(String reason) => RadioListTile<String>( (String reason) => RadioListTile<String>(
title: Text(reason, style: UiTypography.body2r), title: Text(reason, style: UiTypography.body2r),
value: reason, value: reason,
@@ -269,14 +264,14 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), 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 // Additional Notes
return Padding( return Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
@@ -284,16 +279,16 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Text( Text(
"Additional Notes", i18n.additional_notes,
style: UiTypography.headline4m, style: UiTypography.headline4m,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
TextField( TextField(
onChanged: (String v) => _additionalNotes = v, onChanged: (String v) => _additionalNotes = v,
style: UiTypography.body2r, style: UiTypography.body2r,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Add any details...', hintText: i18n.notes_placeholder,
border: OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
maxLines: 3, maxLines: 3,
), ),
@@ -307,14 +302,14 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), 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 // Success
return Padding( return Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
@@ -324,7 +319,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
const Icon(UiIcons.checkCircle, size: 64, color: UiColors.success), const Icon(UiIcons.checkCircle, size: 64, color: UiColors.success),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
"Break Logged!", i18n.success_title,
style: UiTypography.headline1m, style: UiTypography.headline1m,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
@@ -334,7 +329,7 @@ class _LunchBreakDialogState extends State<LunchBreakDialog> {
backgroundColor: UiColors.primary, backgroundColor: UiColors.primary,
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
), ),
child: Text("Close", style: UiTypography.body1m.white), child: Text(i18n.close, style: UiTypography.body1m.white),
), ),
], ],
), ),

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -71,6 +72,7 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.clock_in.swipe;
final Color baseColor = widget.isCheckedIn final Color baseColor = widget.isCheckedIn
? UiColors.success ? UiColors.success
: UiColors.primary; : UiColors.primary;
@@ -110,9 +112,9 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
Text( Text(
widget.isLoading widget.isLoading
? (widget.isCheckedIn ? (widget.isCheckedIn
? "Checking out..." ? i18n.checking_out
: "Checking in...") : i18n.checking_in)
: (widget.isCheckedIn ? "NFC Check Out" : "NFC Check In"), : (widget.isCheckedIn ? i18n.nfc_checkout : i18n.nfc_checkin),
style: UiTypography.body1b.white, style: UiTypography.body1b.white,
), ),
], ],
@@ -157,8 +159,8 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
opacity: 1.0 - progress, opacity: 1.0 - progress,
child: Text( child: Text(
widget.isCheckedIn widget.isCheckedIn
? "Swipe to Check Out" ? i18n.swipe_checkout
: "Swipe to Check In", : i18n.swipe_checkin,
style: UiTypography.body1b, style: UiTypography.body1b,
), ),
), ),
@@ -166,7 +168,7 @@ class _SwipeToCheckInState extends State<SwipeToCheckIn>
if (_isComplete) if (_isComplete)
Center( Center(
child: Text( child: Text(
widget.isCheckedIn ? "Check Out!" : "Check In!", widget.isCheckedIn ? i18n.checkout_complete : i18n.checkin_complete,
style: UiTypography.body1b, style: UiTypography.body1b,
), ),
), ),

View File

@@ -32,6 +32,7 @@ class WorkerHomePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Translations.of(context);
final i18n = t.staff.home; final i18n = t.staff.home;
final bannersI18n = i18n.banners; final bannersI18n = i18n.banners;
final quickI18n = i18n.quick_actions; final quickI18n = i18n.quick_actions;

View File

@@ -6,7 +6,9 @@ import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import '../../domain/repositories/payments_repository.dart'; import '../../domain/repositories/payments_repository.dart';
class PaymentsRepositoryImpl implements PaymentsRepository { class PaymentsRepositoryImpl
with dc.DataErrorHandler
implements PaymentsRepository {
PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
final dc.ExampleConnector _dataConnect; final dc.ExampleConnector _dataConnect;
@@ -27,17 +29,18 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
// 3. Fetch from Data Connect using Firebase UID // 3. Fetch from Data Connect using Firebase UID
final firebase_auth.User? user = _auth.currentUser; final firebase_auth.User? user = _auth.currentUser;
if (user == null) { if (user == null) {
throw Exception('User is not authenticated'); throw const NotAuthenticatedException(
technicalMessage: 'User is not authenticated',
);
} }
try { // This call is protected by parent execution context if called within executeProtected,
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); // otherwise we might need to wrap it if called standalone.
if (response.data.staffs.isNotEmpty) { // For now we assume it's called from public methods which are protected.
_cachedStaffId = response.data.staffs.first.id; final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> response = await _dataConnect.getStaffByUserId(userId: user.uid).execute();
return _cachedStaffId!; if (response.data.staffs.isNotEmpty) {
} _cachedStaffId = response.data.staffs.first.id;
} catch (e) { return _cachedStaffId!;
// Log or handle error
} }
// 4. Fallback // 4. Fallback
@@ -78,55 +81,57 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
@override @override
Future<PaymentSummary> getPaymentSummary() async { Future<PaymentSummary> getPaymentSummary() async {
final String currentStaffId = await _getStaffId(); return executeProtected(() async {
final String currentStaffId = await _getStaffId();
// Fetch recent payments with a limit // Fetch recent payments with a limit
// Note: limit is chained on the query builder // Note: limit is chained on the query builder
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> result = final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> result =
await _dataConnect.listRecentPaymentsByStaffId( await _dataConnect.listRecentPaymentsByStaffId(
staffId: currentStaffId, staffId: currentStaffId,
).limit(100).execute(); ).limit(100).execute();
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = result.data.recentPayments; final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = result.data.recentPayments;
double weekly = 0; double weekly = 0;
double monthly = 0; double monthly = 0;
double pending = 0; double pending = 0;
double total = 0; double total = 0;
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final DateTime startOfWeek = now.subtract(const Duration(days: 7)); final DateTime startOfWeek = now.subtract(const Duration(days: 7));
final DateTime startOfMonth = DateTime(now.year, now.month, 1); final DateTime startOfMonth = DateTime(now.year, now.month, 1);
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) { for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt); final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt);
final double amount = p.invoice.amount; final double amount = p.invoice.amount;
final String? status = p.status?.stringValue; final String? status = p.status?.stringValue;
if (status == 'PENDING') { if (status == 'PENDING') {
pending += amount; pending += amount;
} else if (status == 'PAID') { } else if (status == 'PAID') {
total += amount; total += amount;
if (date != null) { if (date != null) {
if (date.isAfter(startOfWeek)) weekly += amount; if (date.isAfter(startOfWeek)) weekly += amount;
if (date.isAfter(startOfMonth)) monthly += amount; if (date.isAfter(startOfMonth)) monthly += amount;
} }
} }
} }
return PaymentSummary( return PaymentSummary(
weeklyEarnings: weekly, weeklyEarnings: weekly,
monthlyEarnings: monthly, monthlyEarnings: monthly,
pendingEarnings: pending, pendingEarnings: pending,
totalEarnings: total, totalEarnings: total,
); );
});
} }
@override @override
Future<List<StaffPayment>> getPaymentHistory(String period) async { Future<List<StaffPayment>> getPaymentHistory(String period) async {
final String currentStaffId = await _getStaffId(); return executeProtected(() async {
final String currentStaffId = await _getStaffId();
try {
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response =
await _dataConnect await _dataConnect
.listRecentPaymentsByStaffId(staffId: currentStaffId) .listRecentPaymentsByStaffId(staffId: currentStaffId)
@@ -142,9 +147,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
paidAt: _toDateTime(payment.invoice.issueDate), paidAt: _toDateTime(payment.invoice.issueDate),
); );
}).toList(); }).toList();
} catch (e) { });
return <StaffPayment>[];
}
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.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_bloc.dart';
import '../blocs/payments/payments_event.dart'; import '../blocs/payments/payments_event.dart';
import '../blocs/payments/payments_state.dart'; import '../blocs/payments/payments_state.dart';
@@ -30,23 +31,28 @@ class _PaymentsPageState extends State<PaymentsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Translations.of(context);
return BlocProvider<PaymentsBloc>.value( return BlocProvider<PaymentsBloc>.value(
value: _bloc, value: _bloc,
child: Scaffold( child: Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
body: BlocBuilder<PaymentsBloc, PaymentsState>( body: BlocConsumer<PaymentsBloc, PaymentsState>(
listener: (context, state) {
// Error is already shown on the page itself (lines 53-63), no need for snackbar
},
builder: (BuildContext context, PaymentsState state) { builder: (BuildContext context, PaymentsState state) {
if (state is PaymentsLoading) { if (state is PaymentsLoading) {
return Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(
color: UiColors.primary,
),
);
} else if (state is PaymentsError) { } else if (state is PaymentsError) {
return Center( return Center(
child: Text( child: Padding(
'Error: ${state.message}', padding: const EdgeInsets.all(16.0),
style: UiTypography.body2r.textError, child: Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
),
), ),
); );
} else if (state is PaymentsLoaded) { } else if (state is PaymentsLoaded) {

View File

@@ -14,7 +14,9 @@ import '../../domain/repositories/profile_repository.dart';
/// ///
/// Currently uses [ProfileRepositoryMock] from data_connect. /// Currently uses [ProfileRepositoryMock] from data_connect.
/// When Firebase Data Connect is ready, this will be swapped with a real implementation. /// 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]. /// Creates a [ProfileRepositoryImpl].
/// ///
/// Requires a [ExampleConnector] from the data_connect package and [FirebaseAuth]. /// Requires a [ExampleConnector] from the data_connect package and [FirebaseAuth].
@@ -31,37 +33,39 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface {
@override @override
Future<Staff> getStaffProfile() async { Future<Staff> getStaffProfile() async {
final user = firebaseAuth.currentUser; return executeProtected(() async {
if (user == null) { final user = firebaseAuth.currentUser;
throw Exception('User not authenticated'); if (user == null) {
} throw NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final response = await connector.getStaffByUserId(userId: user.uid).execute(); final response = await connector.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isEmpty) { if (response.data.staffs.isEmpty) {
// TODO: Handle user not found properly with domain exception throw const ServerException(technicalMessage: 'Staff not found');
throw Exception('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 // Map the raw data connect object to the Domain Entity
return Staff( return Staff(
id: rawStaff.id, id: rawStaff.id,
authProviderId: rawStaff.userId, authProviderId: rawStaff.userId,
name: rawStaff.fullName, name: rawStaff.fullName,
email: rawStaff.email ?? '', email: rawStaff.email ?? '',
phone: rawStaff.phone, phone: rawStaff.phone,
avatar: rawStaff.photoUrl, avatar: rawStaff.photoUrl,
status: StaffStatus.active, status: StaffStatus.active,
address: rawStaff.addres, address: rawStaff.addres,
totalShifts: rawStaff.totalShifts, totalShifts: rawStaff.totalShifts,
averageRating: rawStaff.averageRating, averageRating: rawStaff.averageRating,
onTimeRate: rawStaff.onTimeRate, onTimeRate: rawStaff.onTimeRate,
noShowCount: rawStaff.noShowCount, noShowCount: rawStaff.noShowCount,
cancellationCount: rawStaff.cancellationCount, cancellationCount: rawStaff.cancellationCount,
reliabilityScore: rawStaff.reliabilityScore, reliabilityScore: rawStaff.reliabilityScore,
); );
});
} }
@override @override

View File

@@ -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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext; import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext;
import 'package:flutter_modular/flutter_modular.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 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/profile_cubit.dart'; import '../blocs/profile_cubit.dart';
import '../blocs/profile_state.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/logout_button.dart';
import '../widgets/profile_header.dart';
import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_grid.dart';
import '../widgets/profile_menu_item.dart'; import '../widgets/profile_menu_item.dart';
import '../widgets/profile_header.dart';
import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_score_bar.dart';
import '../widgets/reliability_stats_card.dart'; import '../widgets/reliability_stats_card.dart';
import '../widgets/reliability_stats_card.dart';
import '../widgets/section_title.dart'; import '../widgets/section_title.dart';
import '../widgets/language_selector_bottom_sheet.dart';
/// The main Staff Profile page. /// The main Staff Profile page.
/// ///
@@ -49,7 +50,7 @@ class StaffProfilePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = t.staff.profile; final i18n = Translations.of(context).staff.profile;
final ProfileCubit cubit = Modular.get<ProfileCubit>(); final ProfileCubit cubit = Modular.get<ProfileCubit>();
// Load profile data on first build // Load profile data on first build
@@ -63,19 +64,34 @@ class StaffProfilePage extends StatelessWidget {
listener: (context, state) { listener: (context, state) {
if (state.status == ProfileStatus.signedOut) { if (state.status == ProfileStatus.signedOut) {
Modular.to.toGetStarted(); 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) { builder: (context, state) {
// Show loading spinner if status is loading // Show loading spinner if status is loading
if (state.status == ProfileStatus.loading) { if (state.status == ProfileStatus.loading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (state.status == ProfileStatus.error) { if (state.status == ProfileStatus.error) {
return Center( return Center(
child: Text( child: Padding(
state.errorMessage ?? 'An error occurred', padding: const EdgeInsets.all(16.0),
style: UiTypography.body1r.textError, 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), SectionTitle(i18n.sections.onboarding),
ProfileMenuGrid( ProfileMenuGrid(
crossAxisCount: 3, crossAxisCount: 3,
children: [ children: [
ProfileMenuItem( ProfileMenuItem(
icon: UiIcons.user, icon: UiIcons.user,
@@ -177,23 +194,18 @@ class StaffProfilePage extends StatelessWidget {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
SectionTitle( SectionTitle(
i18n.header.title.contains("Perfil") i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings",
? "Ajustes"
: "Settings",
), ),
ProfileMenuGrid( ProfileMenuGrid(
crossAxisCount: 3, crossAxisCount: 3,
children: [ children: [
ProfileMenuItem( ProfileMenuItem(
icon: UiIcons.globe, icon: UiIcons.globe,
label: i18n.header.title.contains("Perfil") label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language",
? "Idioma"
: "Language",
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => builder: (context) => const LanguageSelectorBottomSheet(),
const LanguageSelectorBottomSheet(),
); );
}, },
), ),

View File

@@ -1,6 +1,7 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
/// A bottom sheet that allows the user to select their preferred language. /// A bottom sheet that allows the user to select their preferred language.
@@ -14,36 +15,36 @@ class LanguageSelectorBottomSheet extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space6), padding: EdgeInsets.all(UiConstants.space6),
decoration: const BoxDecoration( decoration: BoxDecoration(
color: UiColors.background, color: UiColors.background,
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.radiusBase)),
top: Radius.circular(UiConstants.radiusBase),
),
), ),
child: Column( child: SafeArea(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
t.settings.change_language, Text(
style: UiTypography.headline4m, t.settings.change_language,
textAlign: TextAlign.center, style: UiTypography.headline4m,
), textAlign: TextAlign.center,
const SizedBox(height: UiConstants.space6), ),
_buildLanguageOption( SizedBox(height: UiConstants.space6),
context, _buildLanguageOption(
label: 'English', context,
locale: AppLocale.en, label: 'English',
), locale: AppLocale.en,
const SizedBox(height: UiConstants.space4), ),
_buildLanguageOption( SizedBox(height: UiConstants.space4),
context, _buildLanguageOption(
label: 'Español', context,
locale: AppLocale.es, label: 'Español',
), locale: AppLocale.es,
const SizedBox(height: UiConstants.space6), ),
], SizedBox(height: UiConstants.space6),
],
),
), ),
); );
} }
@@ -72,14 +73,12 @@ class LanguageSelectorBottomSheet extends StatelessWidget {
}, },
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: UiConstants.space4, vertical: UiConstants.space4,
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected ? UiColors.primary.withValues(alpha: 0.1) : UiColors.background,
? UiColors.primary.withValues(alpha: 0.1)
: UiColors.background,
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
border: Border.all( border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border, color: isSelected ? UiColors.primary : UiColors.border,
@@ -91,10 +90,12 @@ class LanguageSelectorBottomSheet extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: isSelected ? UiTypography.body1b.primary : UiTypography.body1r, style: isSelected
? UiTypography.body1b.copyWith(color: UiColors.primary)
: UiTypography.body1r,
), ),
if (isSelected) if (isSelected)
const Icon( Icon(
UiIcons.check, UiIcons.check,
color: UiColors.primary, color: UiColors.primary,
size: 24.0, size: 24.0,

View File

@@ -10,7 +10,9 @@ import '../../domain/repositories/certificates_repository.dart';
/// ///
/// This class handles the communication with the backend via [ExampleConnector]. /// This class handles the communication with the backend via [ExampleConnector].
/// It maps raw generated data types to clean [domain.StaffDocument] entities. /// 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. /// The generated Data Connect SDK client.
final ExampleConnector _dataConnect; final ExampleConnector _dataConnect;
@@ -24,16 +26,17 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
required ExampleConnector dataConnect, required ExampleConnector dataConnect,
required FirebaseAuth firebaseAuth, required FirebaseAuth firebaseAuth,
}) : _dataConnect = dataConnect, }) : _dataConnect = dataConnect,
_firebaseAuth = firebaseAuth; _firebaseAuth = firebaseAuth;
@override @override
Future<List<domain.StaffDocument>> getCertificates() async { Future<List<domain.StaffDocument>> getCertificates() async {
final User? currentUser = _firebaseAuth.currentUser; return executeProtected(() async {
if (currentUser == null) { final User? currentUser = _firebaseAuth.currentUser;
throw Exception('User not authenticated'); if (currentUser == null) {
} throw domain.NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
try {
// Execute the query via DataConnect generated SDK // Execute the query via DataConnect generated SDK
final QueryResult<ListStaffDocumentsByStaffIdData, final QueryResult<ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables> result = ListStaffDocumentsByStaffIdVariables> result =
@@ -46,10 +49,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) => .map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
_mapToDomain(doc)) _mapToDomain(doc))
.toList(); .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]. /// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].

View File

@@ -35,7 +35,19 @@ class CertificatesPage extends StatelessWidget {
if (state.status == CertificatesStatus.failure) { if (state.status == CertificatesStatus.failure) {
return Scaffold( 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), onEditExpiry: () => _showEditExpiryDialog(context, doc),
onRemove: () => _showRemoveConfirmation(context, doc), onRemove: () => _showRemoveConfirmation(context, doc),
onView: () { onView: () {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text(t.staff_certificates.card.opened_snackbar), message: t.staff_certificates.card.opened_snackbar,
duration: const Duration(seconds: 2), type: UiSnackbarType.success,
),
); );
}, },
)), )),

View File

@@ -7,7 +7,9 @@ import 'package:krow_core/core.dart';
import '../../domain/repositories/documents_repository.dart'; import '../../domain/repositories/documents_repository.dart';
/// Implementation of [DocumentsRepository] using Data Connect. /// Implementation of [DocumentsRepository] using Data Connect.
class DocumentsRepositoryImpl implements DocumentsRepository { class DocumentsRepositoryImpl
with DataErrorHandler
implements DocumentsRepository {
final ExampleConnector _dataConnect; final ExampleConnector _dataConnect;
final FirebaseAuth _firebaseAuth; final FirebaseAuth _firebaseAuth;
@@ -19,10 +21,12 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
@override @override
Future<List<domain.StaffDocument>> getDocuments() async { Future<List<domain.StaffDocument>> getDocuments() async {
final User? currentUser = _firebaseAuth.currentUser; return executeProtected(() async {
if (currentUser == null) { final User? currentUser = _firebaseAuth.currentUser;
throw Exception('User not authenticated'); if (currentUser == null) {
} throw domain.NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
/// MOCK IMPLEMENTATION /// MOCK IMPLEMENTATION
/// To be replaced with real data connect query when available /// To be replaced with real data connect query when available
@@ -49,22 +53,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
), ),
]; ];
/* });
try {
final QueryResult<ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables> 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( domain.StaffDocument _mapToDomain(

View File

@@ -25,14 +25,15 @@ class DocumentsPage extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
backgroundColor: UiColors.bgPopup,
leading: IconButton( leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary), icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary),
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.pop(),
), ),
title: Text( title: Text(
t.staff_documents.title, t.staff_documents.title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
),
), ),
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0), preferredSize: const Size.fromHeight(1.0),
@@ -51,11 +52,17 @@ class DocumentsPage extends StatelessWidget {
} }
if (state.status == DocumentsStatus.failure) { if (state.status == DocumentsStatus.failure) {
return Center( return Center(
child: Text( child: Padding(
t.staff_documents.list.error( padding: const EdgeInsets.all(16.0),
message: state.errorMessage ?? 'Unknown', 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( return Center(
child: Text( child: Text(
t.staff_documents.list.empty, t.staff_documents.list.empty,
style: UiTypography.body1m.textSecondary, style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
), ),
); );
} }
return ListView( return ListView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
horizontal: UiConstants.space5,
vertical: UiConstants.space6,
),
children: <Widget>[ children: <Widget>[
DocumentsProgressCard( DocumentsProgressCard(
completedCount: state.completedCount, completedCount: state.completedCount,
totalCount: state.totalCount, totalCount: state.totalCount,
progress: state.progress, progress: state.progress,
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: 16),
...state.documents.map( ...state.documents.map(
(StaffDocument doc) => DocumentCard( (StaffDocument doc) => DocumentCard(
document: doc, document: doc,

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as auth; 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:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart'; 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 '../../domain/repositories/tax_forms_repository.dart';
import '../mappers/tax_form_mapper.dart'; import '../mappers/tax_form_mapper.dart';
class TaxFormsRepositoryImpl implements TaxFormsRepository { class TaxFormsRepositoryImpl
with dc.DataErrorHandler
implements TaxFormsRepository {
TaxFormsRepositoryImpl({ TaxFormsRepositoryImpl({
required this.firebaseAuth, required this.firebaseAuth,
required this.dataConnect, required this.dataConnect,
@@ -21,46 +24,58 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
String _getStaffId() { String _getStaffId() {
final auth.User? user = firebaseAuth.currentUser; final auth.User? user = firebaseAuth.currentUser;
if (user == null) { 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; final String? staffId = dc.StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) { 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; return staffId;
} }
@override @override
Future<List<TaxForm>> getTaxForms() async { Future<List<TaxForm>> getTaxForms() async {
final String staffId = _getStaffId(); return executeProtected(() async {
final QueryResult<dc.GetTaxFormsByStaffIdData, dc.GetTaxFormsByStaffIdVariables> final String staffId = _getStaffId();
result =
await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute();
final List<TaxForm> forms = result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList();
// Check if required forms exist, create if not.
final Set<TaxFormType> 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> final QueryResult<dc.GetTaxFormsByStaffIdData, dc.GetTaxFormsByStaffIdVariables>
result2 = result = await dataConnect
await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute(); .getTaxFormsByStaffId(staffId: staffId)
return result2.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); .execute();
}
return forms; final List<TaxForm> forms =
result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList();
// Check if required forms exist, create if not.
final Set<TaxFormType> 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<void> _createInitialForm(String staffId, TaxFormType type) async { Future<void> _createInitialForm(String staffId, TaxFormType type) async {
@@ -80,45 +95,62 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
@override @override
Future<void> updateI9Form(I9TaxForm form) async { Future<void> updateI9Form(I9TaxForm form) async {
final Map<String, dynamic> data = form.formData; return executeProtected(() async {
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); final Map<String, dynamic> data = form.formData;
_mapCommonFields(builder, data); final dc.UpdateTaxFormVariablesBuilder builder =
_mapI9Fields(builder, data); dataConnect.updateTaxForm(id: form.id);
await builder.execute(); _mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.execute();
});
} }
@override @override
Future<void> submitI9Form(I9TaxForm form) async { Future<void> submitI9Form(I9TaxForm form) async {
final Map<String, dynamic> data = form.formData; return executeProtected(() async {
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); final Map<String, dynamic> data = form.formData;
_mapCommonFields(builder, data); final dc.UpdateTaxFormVariablesBuilder builder =
_mapI9Fields(builder, data); dataConnect.updateTaxForm(id: form.id);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); _mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
} }
@override @override
Future<void> updateW4Form(W4TaxForm form) async { Future<void> updateW4Form(W4TaxForm form) async {
final Map<String, dynamic> data = form.formData; return executeProtected(() async {
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); final Map<String, dynamic> data = form.formData;
_mapCommonFields(builder, data); final dc.UpdateTaxFormVariablesBuilder builder =
_mapW4Fields(builder, data); dataConnect.updateTaxForm(id: form.id);
await builder.execute(); _mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.execute();
});
} }
@override @override
Future<void> submitW4Form(W4TaxForm form) async { Future<void> submitW4Form(W4TaxForm form) async {
final Map<String, dynamic> data = form.formData; return executeProtected(() async {
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id); final Map<String, dynamic> data = form.formData;
_mapCommonFields(builder, data); final dc.UpdateTaxFormVariablesBuilder builder =
_mapW4Fields(builder, data); dataConnect.updateTaxForm(id: form.id);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); _mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
} }
void _mapCommonFields(dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) { void _mapCommonFields(
if (data.containsKey('firstName')) builder.firstName(data['firstName'] as String?); dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('lastName')) builder.lastName(data['lastName'] as String?); if (data.containsKey('firstName'))
if (data.containsKey('middleInitial')) builder.mInitial(data['middleInitial'] as String?); builder.firstName(data['firstName'] as String?);
if (data.containsKey('otherLastNames')) builder.oLastName(data['otherLastNames'] 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')) { if (data.containsKey('dob')) {
final String dob = data['dob'] as String; final String dob = data['dob'] as String;
// Handle both ISO string and MM/dd/yyyy manual entry // Handle both ISO string and MM/dd/yyyy manual entry
@@ -145,70 +177,90 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
} }
} }
if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) { 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('email')) builder.email(data['email'] as String?);
if (data.containsKey('phone')) builder.phone(data['phone'] 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('address'))
if (data.containsKey('aptNumber')) builder.apt(data['aptNumber'] as String?); 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('city')) builder.city(data['city'] as String?);
if (data.containsKey('state')) builder.state(data['state'] 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<String, dynamic> data) { void _mapI9Fields(
dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('citizenshipStatus')) { if (data.containsKey('citizenshipStatus')) {
final String status = data['citizenshipStatus'] as String; final String status = data['citizenshipStatus'] as String;
// Map string to enum if possible, or handle otherwise. // Map string to enum if possible, or handle otherwise.
// Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED // Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED
try { try {
builder.citizen(dc.CitizenshipStatus.values.byName(status.toUpperCase())); builder.citizen(
dc.CitizenshipStatus.values.byName(status.toUpperCase()));
} catch (_) {} } catch (_) {}
} }
if (data.containsKey('uscisNumber')) builder.uscis(data['uscisNumber'] as String?); if (data.containsKey('uscisNumber'))
if (data.containsKey('passportNumber')) builder.passportNumber(data['passportNumber'] as String?); builder.uscis(data['uscisNumber'] as String?);
if (data.containsKey('countryIssuance')) builder.countryIssue(data['countryIssuance'] as String?); if (data.containsKey('passportNumber'))
if (data.containsKey('preparerUsed')) builder.prepartorOrTranslator(data['preparerUsed'] as bool?); builder.passportNumber(data['passportNumber'] as String?);
if (data.containsKey('signature')) builder.signature(data['signature'] 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 // Note: admissionNumber not in builder based on file read
} }
void _mapW4Fields(dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) { void _mapW4Fields(
dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('cityStateZip')) { if (data.containsKey('cityStateZip')) {
final String csz = data['cityStateZip'] as String; final String csz = data['cityStateZip'] as String;
// Extremely basic split: City, State Zip // Extremely basic split: City, State Zip
final List<String> parts = csz.split(','); final List<String> parts = csz.split(',');
if (parts.length >= 2) { if (parts.length >= 2) {
builder.city(parts[0].trim()); builder.city(parts[0].trim());
final String stateZip = parts[1].trim(); final String stateZip = parts[1].trim();
final List<String> szParts = stateZip.split(' '); final List<String> szParts = stateZip.split(' ');
if (szParts.isNotEmpty) builder.state(szParts[0]); if (szParts.isNotEmpty) builder.state(szParts[0]);
if (szParts.length > 1) builder.zipCode(szParts.last); if (szParts.length > 1) builder.zipCode(szParts.last);
} }
} }
if (data.containsKey('filingStatus')) { if (data.containsKey('filingStatus')) {
// MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD // MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD
try { try {
final String status = data['filingStatus'] as String; final String status = data['filingStatus'] as String;
// Simple mapping assumptions: // Simple mapping assumptions:
if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE); if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE);
else if (status.contains('married')) builder.marital(dc.MaritalStatus.MARRIED); else if (status.contains('married'))
else if (status.contains('head')) builder.marital(dc.MaritalStatus.HEAD); builder.marital(dc.MaritalStatus.MARRIED);
} catch (_) {} else if (status.contains('head'))
builder.marital(dc.MaritalStatus.HEAD);
} catch (_) {}
} }
if (data.containsKey('multipleJobs')) builder.multipleJob(data['multipleJobs'] as bool?); if (data.containsKey('multipleJobs'))
if (data.containsKey('qualifyingChildren')) builder.childrens(data['qualifyingChildren'] as int?); builder.multipleJob(data['multipleJobs'] as bool?);
if (data.containsKey('otherDependents')) builder.otherDeps(data['otherDependents'] as int?); if (data.containsKey('qualifyingChildren'))
builder.childrens(data['qualifyingChildren'] as int?);
if (data.containsKey('otherDependents'))
builder.otherDeps(data['otherDependents'] as int?);
if (data.containsKey('otherIncome')) { if (data.containsKey('otherIncome')) {
builder.otherInconme(double.tryParse(data['otherIncome'].toString())); builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
} }
if (data.containsKey('deductions')) { if (data.containsKey('deductions')) {
builder.deductions(double.tryParse(data['deductions'].toString())); builder.deductions(double.tryParse(data['deductions'].toString()));
} }
if (data.containsKey('extraWithholding')) { 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?);
} }
} }

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
@@ -76,6 +77,15 @@ class _FormI9PageState extends State<FormI9Page> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff_compliance.tax_forms.i9;
final List<Map<String, String>> steps = <Map<String, String>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.steps.personal_sub},
<String, String>{'title': i18n.steps.address, 'subtitle': i18n.steps.address_sub},
<String, String>{'title': i18n.steps.citizenship, 'subtitle': i18n.steps.citizenship_sub},
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.steps.review_sub},
];
return BlocProvider<FormI9Cubit>.value( return BlocProvider<FormI9Cubit>.value(
value: Modular.get<FormI9Cubit>(), value: Modular.get<FormI9Cubit>(),
child: BlocConsumer<FormI9Cubit, FormI9State>( child: BlocConsumer<FormI9Cubit, FormI9State>(
@@ -83,34 +93,32 @@ class _FormI9PageState extends State<FormI9Page> {
if (state.status == FormI9Status.success) { if (state.status == FormI9Status.success) {
// Success view is handled by state check in build or we can navigate // Success view is handled by state check in build or we can navigate
} else if (state.status == FormI9Status.failure) { } else if (state.status == FormI9Status.failure) {
final ScaffoldMessengerState messenger = UiSnackbar.show(
ScaffoldMessenger.of(context); context,
messenger.hideCurrentSnackBar(); message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
messenger.showSnackBar( type: UiSnackbarType.error,
SnackBar( margin: const EdgeInsets.only(left: 16, right: 16, bottom: 100),
content: Text(state.errorMessage ?? 'An error occurred'),
),
); );
} }
}, },
builder: (BuildContext context, FormI9State state) { builder: (BuildContext context, FormI9State state) {
if (state.status == FormI9Status.success) return _buildSuccessView(); if (state.status == FormI9Status.success) return _buildSuccessView(i18n);
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
body: Column( body: Column(
children: <Widget>[ children: <Widget>[
_buildHeader(context, state), _buildHeader(context, state, steps, i18n),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5, horizontal: UiConstants.space5,
vertical: UiConstants.space6, 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<FormI9Page> {
); );
} }
Widget _buildSuccessView() { Widget _buildSuccessView(TranslationsStaffComplianceTaxFormsI9En i18n) {
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
body: Center( body: Center(
@@ -150,12 +158,12 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'Form I-9 Submitted!', i18n.submitted_title,
style: UiTypography.headline4m.textPrimary, style: UiTypography.headline4m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'Your employment eligibility verification has been submitted.', i18n.submitted_desc,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
@@ -175,7 +183,7 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
elevation: 0, 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<FormI9Page> {
); );
} }
Widget _buildHeader(BuildContext context, FormI9State state) { Widget _buildHeader(
BuildContext context,
FormI9State state,
List<Map<String, String>> steps,
TranslationsStaffComplianceTaxFormsI9En i18n,
) {
return Container( return Container(
color: UiColors.primary, color: UiColors.primary,
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
@@ -213,11 +226,11 @@ class _FormI9PageState extends State<FormI9Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Form I-9', i18n.title,
style: UiTypography.headline4m.white, style: UiTypography.headline4m.white,
), ),
Text( Text(
'Employment Eligibility Verification', i18n.subtitle,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
@@ -228,12 +241,12 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Row( Row(
children: _steps children: steps
.asMap() .asMap()
.entries .entries
.map((MapEntry<int, Map<String, String>> entry) { .map((MapEntry<int, Map<String, String>> entry) {
final int idx = entry.key; final int idx = entry.key;
final bool isLast = idx == _steps.length - 1; final bool isLast = idx == steps.length - 1;
return Expanded( return Expanded(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
@@ -259,14 +272,17 @@ class _FormI9PageState extends State<FormI9Page> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(
'Step ${state.currentStep + 1} of ${_steps.length}', i18n.step_label(
current: (state.currentStep + 1).toString(),
total: steps.length.toString(),
),
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
), ),
Expanded( Expanded(
child: Text( child: Text(
_steps[state.currentStep]['title']!, steps[state.currentStep]['title']!,
textAlign: TextAlign.end, textAlign: TextAlign.end,
style: UiTypography.body3m.white.copyWith( style: UiTypography.body3m.white.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -280,16 +296,20 @@ class _FormI9PageState extends State<FormI9Page> {
); );
} }
Widget _buildCurrentStep(BuildContext context, FormI9State state) { Widget _buildCurrentStep(
BuildContext context,
FormI9State state,
TranslationsStaffComplianceTaxFormsI9En i18n,
) {
switch (state.currentStep) { switch (state.currentStep) {
case 0: case 0:
return _buildStep1(context, state); return _buildStep1(context, state, i18n);
case 1: case 1:
return _buildStep2(context, state); return _buildStep2(context, state, i18n);
case 2: case 2:
return _buildStep3(context, state); return _buildStep3(context, state, i18n);
case 3: case 3:
return _buildStep4(context, state); return _buildStep4(context, state, i18n);
default: default:
return Container(); return Container();
} }
@@ -347,26 +367,30 @@ class _FormI9PageState extends State<FormI9Page> {
); );
} }
Widget _buildStep1(BuildContext context, FormI9State state) { Widget _buildStep1(
BuildContext context,
FormI9State state,
TranslationsStaffComplianceTaxFormsI9En i18n,
) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Row( Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _buildTextField( child: _buildTextField(
'First Name *', i18n.fields.first_name,
value: state.firstName, value: state.firstName,
onChanged: (String val) => context.read<FormI9Cubit>().firstNameChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().firstNameChanged(val),
placeholder: 'John', placeholder: i18n.fields.hints.first_name,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _buildTextField( child: _buildTextField(
'Last Name *', i18n.fields.last_name,
value: state.lastName, value: state.lastName,
onChanged: (String val) => context.read<FormI9Cubit>().lastNameChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().lastNameChanged(val),
placeholder: 'Smith', placeholder: i18n.fields.hints.last_name,
), ),
), ),
], ],
@@ -376,37 +400,37 @@ class _FormI9PageState extends State<FormI9Page> {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _buildTextField( child: _buildTextField(
'Middle Initial', i18n.fields.middle_initial,
value: state.middleInitial, value: state.middleInitial,
onChanged: (String val) => context.read<FormI9Cubit>().middleInitialChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().middleInitialChanged(val),
placeholder: 'A', placeholder: i18n.fields.hints.middle_initial,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
flex: 2, flex: 2,
child: _buildTextField( child: _buildTextField(
'Other Last Names', i18n.fields.other_last_names,
value: state.otherLastNames, value: state.otherLastNames,
onChanged: (String val) => context.read<FormI9Cubit>().otherLastNamesChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().otherLastNamesChanged(val),
placeholder: 'Maiden name (if any)', placeholder: i18n.fields.maiden_name,
), ),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Date of Birth *', i18n.fields.dob,
value: state.dob, value: state.dob,
onChanged: (String val) => context.read<FormI9Cubit>().dobChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().dobChanged(val),
placeholder: 'MM/DD/YYYY', placeholder: i18n.fields.hints.dob,
keyboardType: TextInputType.datetime, keyboardType: TextInputType.datetime,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Social Security Number *', i18n.fields.ssn,
value: state.ssn, value: state.ssn,
placeholder: 'XXX-XX-XXXX', placeholder: i18n.fields.hints.ssn,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (String val) { onChanged: (String val) {
String text = val.replaceAll(RegExp(r'\D'), ''); String text = val.replaceAll(RegExp(r'\D'), '');
@@ -416,39 +440,43 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Email Address', i18n.fields.email,
value: state.email, value: state.email,
onChanged: (String val) => context.read<FormI9Cubit>().emailChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().emailChanged(val),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
placeholder: 'john.smith@example.com', placeholder: i18n.fields.hints.email,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Phone Number', i18n.fields.phone,
value: state.phone, value: state.phone,
onChanged: (String val) => context.read<FormI9Cubit>().phoneChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().phoneChanged(val),
keyboardType: TextInputType.phone, 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( return Column(
children: <Widget>[ children: <Widget>[
_buildTextField( _buildTextField(
'Address (Street Number and Name) *', i18n.fields.address_long,
value: state.address, value: state.address,
onChanged: (String val) => context.read<FormI9Cubit>().addressChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().addressChanged(val),
placeholder: '123 Main Street', placeholder: i18n.fields.hints.address,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Apt. Number', i18n.fields.apt,
value: state.aptNumber, value: state.aptNumber,
onChanged: (String val) => context.read<FormI9Cubit>().aptNumberChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().aptNumberChanged(val),
placeholder: '4B', placeholder: i18n.fields.hints.apt,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -456,10 +484,10 @@ class _FormI9PageState extends State<FormI9Page> {
Expanded( Expanded(
flex: 2, flex: 2,
child: _buildTextField( child: _buildTextField(
'City or Town *', i18n.fields.city,
value: state.city, value: state.city,
onChanged: (String val) => context.read<FormI9Cubit>().cityChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().cityChanged(val),
placeholder: 'San Francisco', placeholder: i18n.fields.hints.city,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -468,7 +496,7 @@ class _FormI9PageState extends State<FormI9Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'State *', i18n.fields.state,
style: UiTypography.body3m.textSecondary.copyWith( style: UiTypography.body3m.textSecondary.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -507,22 +535,26 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'ZIP Code *', i18n.fields.zip,
value: state.zipCode, value: state.zipCode,
onChanged: (String val) => context.read<FormI9Cubit>().zipCodeChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().zipCodeChanged(val),
placeholder: '94103', placeholder: i18n.fields.hints.zip,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
], ],
); );
} }
Widget _buildStep3(BuildContext context, FormI9State state) { Widget _buildStep3(
BuildContext context,
FormI9State state,
TranslationsStaffComplianceTaxFormsI9En i18n,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'I attest, under penalty of perjury, that I am (check one of the following boxes):', i18n.fields.attestation,
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
@@ -530,29 +562,29 @@ class _FormI9PageState extends State<FormI9Page> {
context, context,
state, state,
'CITIZEN', 'CITIZEN',
'1. A citizen of the United States', i18n.fields.citizen,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRadioOption( _buildRadioOption(
context, context,
state, state,
'NONCITIZEN', 'NONCITIZEN',
'2. A noncitizen national of the United States', i18n.fields.noncitizen,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRadioOption( _buildRadioOption(
context, context,
state, state,
'PERMANENT_RESIDENT', 'PERMANENT_RESIDENT',
'3. A lawful permanent resident', i18n.fields.permanent_resident,
child: state.citizenshipStatus == 'PERMANENT_RESIDENT' child: state.citizenshipStatus == 'PERMANENT_RESIDENT'
? Padding( ? Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: _buildTextField( child: _buildTextField(
'USCIS Number', i18n.fields.uscis_number_label,
value: state.uscisNumber, value: state.uscisNumber,
onChanged: (String val) => context.read<FormI9Cubit>().uscisNumberChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().uscisNumberChanged(val),
placeholder: 'A-123456789', placeholder: i18n.fields.hints.uscis,
), ),
) )
: null, : null,
@@ -562,26 +594,26 @@ class _FormI9PageState extends State<FormI9Page> {
context, context,
state, state,
'ALIEN', 'ALIEN',
'4. An alien authorized to work', i18n.fields.alien,
child: state.citizenshipStatus == 'ALIEN' child: state.citizenshipStatus == 'ALIEN'
? Padding( ? Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
_buildTextField( _buildTextField(
'USCIS/Admission Number', i18n.fields.admission_number,
value: state.admissionNumber, value: state.admissionNumber,
onChanged: (String val) => context.read<FormI9Cubit>().admissionNumberChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().admissionNumberChanged(val),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildTextField( _buildTextField(
'Foreign Passport Number', i18n.fields.passport,
value: state.passportNumber, value: state.passportNumber,
onChanged: (String val) => context.read<FormI9Cubit>().passportNumberChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().passportNumberChanged(val),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildTextField( _buildTextField(
'Country of Issuance', i18n.fields.country,
value: state.countryIssuance, value: state.countryIssuance,
onChanged: (String val) => context.read<FormI9Cubit>().countryIssuanceChanged(val), onChanged: (String val) => context.read<FormI9Cubit>().countryIssuanceChanged(val),
), ),
@@ -645,7 +677,11 @@ class _FormI9PageState extends State<FormI9Page> {
); );
} }
Widget _buildStep4(BuildContext context, FormI9State state) { Widget _buildStep4(
BuildContext context,
FormI9State state,
TranslationsStaffComplianceTaxFormsI9En i18n,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
@@ -660,18 +696,18 @@ class _FormI9PageState extends State<FormI9Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Summary', i18n.fields.summary_title,
style: UiTypography.headline4m.copyWith(fontSize: 14), style: UiTypography.headline4m.copyWith(fontSize: 14),
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
_buildSummaryRow('Name', '${state.firstName} ${state.lastName}'), _buildSummaryRow(i18n.fields.summary_name, '${state.firstName} ${state.lastName}'),
_buildSummaryRow('Address', '${state.address}, ${state.city}'), _buildSummaryRow(i18n.fields.summary_address, '${state.address}, ${state.city}'),
_buildSummaryRow( _buildSummaryRow(
'SSN', i18n.fields.summary_ssn,
'***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}',
), ),
_buildSummaryRow( _buildSummaryRow(
'Citizenship', i18n.fields.summary_citizenship,
_getReadableCitizenship(state.citizenshipStatus), _getReadableCitizenship(state.citizenshipStatus),
), ),
], ],
@@ -685,7 +721,7 @@ class _FormI9PageState extends State<FormI9Page> {
}, },
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text( title: Text(
'I used a preparer or translator', i18n.fields.preparer,
style: UiTypography.body2r.textPrimary, style: UiTypography.body2r.textPrimary,
), ),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
@@ -699,13 +735,13 @@ class _FormI9PageState extends State<FormI9Page> {
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
), ),
child: Text( 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), style: UiTypography.body3r.textWarning.copyWith(fontSize: 12),
), ),
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
'Signature (type your full name) *', i18n.fields.signature_label,
style: UiTypography.body3m.textSecondary, style: UiTypography.body3m.textSecondary,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -717,7 +753,7 @@ class _FormI9PageState extends State<FormI9Page> {
onChanged: (String val) => onChanged: (String val) =>
context.read<FormI9Cubit>().signatureChanged(val), context.read<FormI9Cubit>().signatureChanged(val),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type your full name', hintText: i18n.fields.signature_hint,
filled: true, filled: true,
fillColor: UiColors.bgPopup, fillColor: UiColors.bgPopup,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -741,7 +777,7 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'Date', i18n.fields.date_label,
style: UiTypography.body3m.textSecondary, style: UiTypography.body3m.textSecondary,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -788,21 +824,28 @@ class _FormI9PageState extends State<FormI9Page> {
} }
String _getReadableCitizenship(String status) { String _getReadableCitizenship(String status) {
final i18n = Translations.of(context).staff_compliance.tax_forms.i9.fields;
switch (status) { switch (status) {
case 'CITIZEN': case 'CITIZEN':
return 'US Citizen'; return i18n.status_us_citizen;
case 'NONCITIZEN': case 'NONCITIZEN':
return 'Noncitizen National'; return i18n.status_noncitizen;
case 'PERMANENT_RESIDENT': case 'PERMANENT_RESIDENT':
return 'Permanent Resident'; return i18n.status_permanent_resident;
case 'ALIEN': case 'ALIEN':
return 'Alien Authorized to Work'; return i18n.status_alien;
default: default:
return 'Unknown'; return i18n.status_unknown;
} }
} }
Widget _buildFooter(BuildContext context, FormI9State state) { Widget _buildFooter(
BuildContext context,
FormI9State state,
List<Map<String, String>> steps,
) {
final i18n = Translations.of(context).staff_compliance.tax_forms.i9;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -837,7 +880,7 @@ class _FormI9PageState extends State<FormI9Page> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Back', i18n.back,
style: UiTypography.body2r.textPrimary, style: UiTypography.body2r.textPrimary,
), ),
], ],
@@ -878,11 +921,11 @@ class _FormI9PageState extends State<FormI9Page> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text( Text(
state.currentStep == _steps.length - 1 state.currentStep == steps.length - 1
? 'Sign & Submit' ? i18n.submit
: 'Continue', : i18n.kContinue,
), ),
if (state.currentStep < _steps.length - 1) ...<Widget>[ if (state.currentStep < steps.length - 1) ...<Widget>[
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white),
], ],

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension; import 'package:flutter_modular/flutter_modular.dart' hide ModularWatchExtension;
@@ -122,6 +123,17 @@ class _FormW4PageState extends State<FormW4Page> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff_compliance.tax_forms.w4;
final List<Map<String, String>> steps = <Map<String, String>>[
<String, String>{'title': i18n.steps.personal, 'subtitle': i18n.step_label(current: '1', total: '5')},
<String, String>{'title': i18n.steps.filing, 'subtitle': i18n.step_label(current: '1c', total: '5')},
<String, String>{'title': i18n.steps.multiple_jobs, 'subtitle': i18n.step_label(current: '2', total: '5')},
<String, String>{'title': i18n.steps.dependents, 'subtitle': i18n.step_label(current: '3', total: '5')},
<String, String>{'title': i18n.steps.adjustments, 'subtitle': i18n.step_label(current: '4', total: '5')},
<String, String>{'title': i18n.steps.review, 'subtitle': i18n.step_label(current: '5', total: '5')},
];
return BlocProvider<FormW4Cubit>.value( return BlocProvider<FormW4Cubit>.value(
value: Modular.get<FormW4Cubit>(), value: Modular.get<FormW4Cubit>(),
child: BlocConsumer<FormW4Cubit, FormW4State>( child: BlocConsumer<FormW4Cubit, FormW4State>(
@@ -129,31 +141,32 @@ class _FormW4PageState extends State<FormW4Page> {
if (state.status == FormW4Status.success) { if (state.status == FormW4Status.success) {
// Handled in builder // Handled in builder
} else if (state.status == FormW4Status.failure) { } else if (state.status == FormW4Status.failure) {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); UiSnackbar.show(
messenger.hideCurrentSnackBar(); context,
messenger.showSnackBar( message: translateErrorKey(state.errorMessage ?? 'An error occurred'),
SnackBar(content: Text(state.errorMessage ?? 'An error occurred')), type: UiSnackbarType.error,
margin: const EdgeInsets.only(left: 16, right: 16, bottom: 100),
); );
} }
}, },
builder: (BuildContext context, FormW4State state) { builder: (BuildContext context, FormW4State state) {
if (state.status == FormW4Status.success) return _buildSuccessView(); if (state.status == FormW4Status.success) return _buildSuccessView(i18n);
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
body: Column( body: Column(
children: <Widget>[ children: <Widget>[
_buildHeader(context, state), _buildHeader(context, state, steps, i18n),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5, horizontal: UiConstants.space5,
vertical: UiConstants.space6, 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<FormW4Page> {
); );
} }
Widget _buildSuccessView() { Widget _buildSuccessView(TranslationsStaffComplianceTaxFormsW4En i18n) {
return Scaffold( return Scaffold(
backgroundColor: UiColors.background, backgroundColor: UiColors.background,
body: Center( body: Center(
@@ -193,12 +206,12 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'Form W-4 Submitted!', i18n.submitted_title,
style: UiTypography.headline4m.textPrimary, style: UiTypography.headline4m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'Your withholding certificate has been submitted to your employer.', i18n.submitted_desc,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
@@ -218,7 +231,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
elevation: 0, elevation: 0,
), ),
child: const Text('Back to Documents'), child: Text(i18n.back_to_docs),
), ),
), ),
], ],
@@ -229,7 +242,12 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildHeader(BuildContext context, FormW4State state) { Widget _buildHeader(
BuildContext context,
FormW4State state,
List<Map<String, String>> steps,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Container( return Container(
color: UiColors.primary, color: UiColors.primary,
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
@@ -256,11 +274,11 @@ class _FormW4PageState extends State<FormW4Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Form W-4', i18n.title,
style: UiTypography.headline4m.white, style: UiTypography.headline4m.white,
), ),
Text( Text(
'Employee\'s Withholding Certificate', i18n.subtitle,
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
@@ -271,12 +289,12 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Row( Row(
children: _steps children: steps
.asMap() .asMap()
.entries .entries
.map((MapEntry<int, Map<String, String>> entry) { .map((MapEntry<int, Map<String, String>> entry) {
final int idx = entry.key; final int idx = entry.key;
final bool isLast = idx == _steps.length - 1; final bool isLast = idx == steps.length - 1;
return Expanded( return Expanded(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
@@ -302,13 +320,16 @@ class _FormW4PageState extends State<FormW4Page> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(
'Step ${state.currentStep + 1} of ${_steps.length}', i18n.step_label(
current: (state.currentStep + 1).toString(),
total: steps.length.toString(),
),
style: UiTypography.body3r.copyWith( style: UiTypography.body3r.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
), ),
), ),
Text( Text(
_steps[state.currentStep]['title']!, steps[state.currentStep]['title']!,
style: UiTypography.body3m.white.copyWith( style: UiTypography.body3m.white.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -320,20 +341,24 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildCurrentStep(BuildContext context, FormW4State state) { Widget _buildCurrentStep(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
switch (state.currentStep) { switch (state.currentStep) {
case 0: case 0:
return _buildStep1(context, state); return _buildStep1(context, state, i18n);
case 1: case 1:
return _buildStep2(context, state); return _buildStep2(context, state, i18n);
case 2: case 2:
return _buildStep3(context, state); return _buildStep3(context, state, i18n);
case 3: case 3:
return _buildStep4(context, state); return _buildStep4(context, state, i18n);
case 4: case 4:
return _buildStep5(context, state); return _buildStep5(context, state, i18n);
case 5: case 5:
return _buildStep6(context, state); return _buildStep6(context, state, i18n);
default: default:
return Container(); return Container();
} }
@@ -391,35 +416,39 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildStep1(BuildContext context, FormW4State state) { Widget _buildStep1(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Row( Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _buildTextField( child: _buildTextField(
'First Name *', i18n.fields.first_name,
value: state.firstName, value: state.firstName,
onChanged: (String val) => context.read<FormW4Cubit>().firstNameChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().firstNameChanged(val),
placeholder: 'John', placeholder: i18n.fields.placeholder_john,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _buildTextField( child: _buildTextField(
'Last Name *', i18n.fields.last_name,
value: state.lastName, value: state.lastName,
onChanged: (String val) => context.read<FormW4Cubit>().lastNameChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().lastNameChanged(val),
placeholder: 'Smith', placeholder: i18n.fields.placeholder_smith,
), ),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Social Security Number *', i18n.fields.ssn,
value: state.ssn, value: state.ssn,
placeholder: 'XXX-XX-XXXX', placeholder: i18n.fields.placeholder_ssn,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (String val) { onChanged: (String val) {
String text = val.replaceAll(RegExp(r'\D'), ''); String text = val.replaceAll(RegExp(r'\D'), '');
@@ -429,23 +458,27 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'Address *', i18n.fields.address,
value: state.address, value: state.address,
onChanged: (String val) => context.read<FormW4Cubit>().addressChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().addressChanged(val),
placeholder: '123 Main Street', placeholder: i18n.fields.placeholder_address,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTextField( _buildTextField(
'City, State, ZIP', i18n.fields.city_state_zip,
value: state.cityStateZip, value: state.cityStateZip,
onChanged: (String val) => context.read<FormW4Cubit>().cityStateZipChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().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( return Column(
children: <Widget>[ children: <Widget>[
Container( Container(
@@ -460,7 +493,7 @@ class _FormW4PageState extends State<FormW4Page> {
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Text( child: Text(
'Your filing status determines your standard deduction and tax rates.', i18n.fields.filing_info,
style: UiTypography.body2r.textPrimary, style: UiTypography.body2r.textPrimary,
), ),
), ),
@@ -472,7 +505,7 @@ class _FormW4PageState extends State<FormW4Page> {
context, context,
state, state,
'SINGLE', 'SINGLE',
'Single or Married filing separately', i18n.fields.single,
null, null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -480,7 +513,7 @@ class _FormW4PageState extends State<FormW4Page> {
context, context,
state, state,
'MARRIED', 'MARRIED',
'Married filing jointly or Qualifying surviving spouse', i18n.fields.married,
null, null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -488,8 +521,8 @@ class _FormW4PageState extends State<FormW4Page> {
context, context,
state, state,
'HEAD', 'HEAD',
'Head of household', i18n.fields.head,
'Check only if you\'re unmarried and pay more than half the costs of keeping up a home', i18n.fields.head_desc,
), ),
], ],
); );
@@ -555,7 +588,11 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildStep3(BuildContext context, FormW4State state) { Widget _buildStep3(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Container( Container(
@@ -578,12 +615,12 @@ class _FormW4PageState extends State<FormW4Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'When to complete this step?', i18n.fields.multiple_jobs_title,
style: UiTypography.body2m.accent, style: UiTypography.body2m.accent,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( 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, style: UiTypography.body3r.accent,
), ),
], ],
@@ -632,12 +669,12 @@ class _FormW4PageState extends State<FormW4Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'I have multiple jobs or my spouse works', i18n.fields.multiple_jobs_check,
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Check this box if there are only two jobs total', i18n.fields.two_jobs_desc,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
], ],
@@ -649,7 +686,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'If this does not apply, you can continue to the next step', i18n.fields.multiple_jobs_not_apply,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
@@ -657,7 +694,11 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildStep4(BuildContext context, FormW4State state) { Widget _buildStep4(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
Container( Container(
@@ -672,7 +713,7 @@ class _FormW4PageState extends State<FormW4Page> {
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Text( 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, style: UiTypography.body2r.textPrimary,
), ),
), ),
@@ -692,8 +733,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildCounter( _buildCounter(
context, context,
state, state,
'Qualifying children under age 17', i18n.fields.children_under_17,
'\$2,000 each', i18n.fields.children_each,
(FormW4State s) => s.qualifyingChildren, (FormW4State s) => s.qualifyingChildren,
(int val) => context.read<FormW4Cubit>().qualifyingChildrenChanged(val), (int val) => context.read<FormW4Cubit>().qualifyingChildrenChanged(val),
), ),
@@ -704,8 +745,8 @@ class _FormW4PageState extends State<FormW4Page> {
_buildCounter( _buildCounter(
context, context,
state, state,
'Other dependents', i18n.fields.other_dependents,
'\$500 each', i18n.fields.other_each,
(FormW4State s) => s.otherDependents, (FormW4State s) => s.otherDependents,
(int val) => context.read<FormW4Cubit>().otherDependentsChanged(val), (int val) => context.read<FormW4Cubit>().otherDependentsChanged(val),
), ),
@@ -723,9 +764,9 @@ class _FormW4PageState extends State<FormW4Page> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
const Text( Text(
'Total credits (Step 3)', i18n.fields.total_credits,
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF166534), color: Color(0xFF166534),
), ),
@@ -824,56 +865,60 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildStep5(BuildContext context, FormW4State state) { Widget _buildStep5(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'These adjustments are optional. You can skip them if they don\'t apply.', i18n.fields.adjustments_info,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildTextField( _buildTextField(
'4(a) Other income (not from jobs)', i18n.fields.other_income,
value: state.otherIncome, value: state.otherIncome,
onChanged: (String val) => context.read<FormW4Cubit>().otherIncomeChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().otherIncomeChanged(val),
placeholder: '\$0', placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 4, bottom: 16), padding: const EdgeInsets.only(top: 4, bottom: 16),
child: Text( child: Text(
'Include interest, dividends, retirement income', i18n.fields.other_income_desc,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
), ),
_buildTextField( _buildTextField(
'4(b) Deductions', i18n.fields.deductions,
value: state.deductions, value: state.deductions,
onChanged: (String val) => context.read<FormW4Cubit>().deductionsChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().deductionsChanged(val),
placeholder: '\$0', placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 4, bottom: 16), padding: const EdgeInsets.only(top: 4, bottom: 16),
child: Text( child: Text(
'If you expect to claim deductions other than the standard deduction', i18n.fields.deductions_desc,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
), ),
_buildTextField( _buildTextField(
'4(c) Extra withholding', i18n.fields.extra_withholding,
value: state.extraWithholding, value: state.extraWithholding,
onChanged: (String val) => context.read<FormW4Cubit>().extraWithholdingChanged(val), onChanged: (String val) => context.read<FormW4Cubit>().extraWithholdingChanged(val),
placeholder: '\$0', placeholder: i18n.fields.hints.zero,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 4, bottom: 16), padding: const EdgeInsets.only(top: 4, bottom: 16),
child: Text( child: Text(
'Any additional tax you want withheld each pay period', i18n.fields.extra_withholding_desc,
style: UiTypography.body3r.textSecondary, style: UiTypography.body3r.textSecondary,
), ),
), ),
@@ -881,7 +926,11 @@ class _FormW4PageState extends State<FormW4Page> {
); );
} }
Widget _buildStep6(BuildContext context, FormW4State state) { Widget _buildStep6(
BuildContext context,
FormW4State state,
TranslationsStaffComplianceTaxFormsW4En i18n,
) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
@@ -896,25 +945,25 @@ class _FormW4PageState extends State<FormW4Page> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'Your W-4 Summary', i18n.fields.summary_title,
style: UiTypography.headline4m.copyWith(fontSize: 14), style: UiTypography.headline4m.copyWith(fontSize: 14),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildSummaryRow( _buildSummaryRow(
'Name', i18n.fields.summary_name,
'${state.firstName} ${state.lastName}', '${state.firstName} ${state.lastName}',
), ),
_buildSummaryRow( _buildSummaryRow(
'SSN', i18n.fields.summary_ssn,
'***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}', '***-**-${state.ssn.length >= 4 ? state.ssn.substring(state.ssn.length - 4) : '****'}',
), ),
_buildSummaryRow( _buildSummaryRow(
'Filing Status', i18n.fields.summary_filing,
_getFilingStatusLabel(state.filingStatus), _getFilingStatusLabel(state.filingStatus),
), ),
if (_totalCredits(state) > 0) if (_totalCredits(state) > 0)
_buildSummaryRow( _buildSummaryRow(
'Credits', i18n.fields.summary_credits,
'\$${_totalCredits(state)}', '\$${_totalCredits(state)}',
valueColor: Colors.green[700], valueColor: Colors.green[700],
), ),
@@ -929,13 +978,13 @@ class _FormW4PageState extends State<FormW4Page> {
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
), ),
child: Text( 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), style: UiTypography.body3r.textWarning.copyWith(fontSize: 12),
), ),
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Text( Text(
'Signature (type your full name) *', i18n.fields.signature_label,
style: UiTypography.body3m.textSecondary, style: UiTypography.body3m.textSecondary,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -947,7 +996,7 @@ class _FormW4PageState extends State<FormW4Page> {
onChanged: (String val) => onChanged: (String val) =>
context.read<FormW4Cubit>().signatureChanged(val), context.read<FormW4Cubit>().signatureChanged(val),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type your full name', hintText: i18n.fields.signature_hint,
filled: true, filled: true,
fillColor: UiColors.bgPopup, fillColor: UiColors.bgPopup,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -971,7 +1020,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'Date', i18n.fields.date_label,
style: UiTypography.body3m.textSecondary, style: UiTypography.body3m.textSecondary,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -1017,19 +1066,26 @@ class _FormW4PageState extends State<FormW4Page> {
} }
String _getFilingStatusLabel(String status) { String _getFilingStatusLabel(String status) {
final i18n = Translations.of(context).staff_compliance.tax_forms.w4.fields;
switch (status) { switch (status) {
case 'single': case 'SINGLE':
return 'Single'; return i18n.status_single;
case 'married': case 'MARRIED':
return 'Married'; return i18n.status_married;
case 'head_of_household': case 'HEAD':
return 'Head of Household'; return i18n.status_head;
default: default:
return status; return status;
} }
} }
Widget _buildFooter(BuildContext context, FormW4State state) { Widget _buildFooter(
BuildContext context,
FormW4State state,
List<Map<String, String>> steps,
) {
final i18n = Translations.of(context).staff_compliance.tax_forms.w4;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -1064,7 +1120,7 @@ class _FormW4PageState extends State<FormW4Page> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Back', i18n.fields.back,
style: UiTypography.body2r.textPrimary, style: UiTypography.body2r.textPrimary,
), ),
], ],
@@ -1105,11 +1161,11 @@ class _FormW4PageState extends State<FormW4Page> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text( Text(
state.currentStep == _steps.length - 1 state.currentStep == steps.length - 1
? 'Submit Form' ? i18n.fields.submit
: 'Continue', : i18n.fields.kContinue,
), ),
if (state.currentStep < _steps.length - 1) ...<Widget>[ if (state.currentStep < steps.length - 1) ...<Widget>[
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white), const Icon(UiIcons.arrowRight, size: 16, color: UiColors.white),
], ],

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -62,7 +63,15 @@ class TaxFormsPage extends StatelessWidget {
if (state.status == TaxFormsStatus.failure) { if (state.status == TaxFormsStatus.failure) {
return Center( 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,
),
),
); );
} }

View File

@@ -5,7 +5,9 @@ import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/bank_account_repository.dart'; import '../../domain/repositories/bank_account_repository.dart';
/// Implementation of [BankAccountRepository] that integrates with Data Connect. /// Implementation of [BankAccountRepository] that integrates with Data Connect.
class BankAccountRepositoryImpl implements BankAccountRepository { class BankAccountRepositoryImpl
with DataErrorHandler
implements BankAccountRepository {
/// Creates a [BankAccountRepositoryImpl]. /// Creates a [BankAccountRepositoryImpl].
const BankAccountRepositoryImpl({ const BankAccountRepositoryImpl({
required this.dataConnect, required this.dataConnect,
@@ -19,60 +21,65 @@ class BankAccountRepositoryImpl implements BankAccountRepository {
@override @override
Future<List<BankAccount>> getAccounts() async { Future<List<BankAccount>> getAccounts() async {
final String staffId = _getStaffId(); return executeProtected(() async {
final String staffId = _getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
result = await dataConnect result = await dataConnect
.getAccountsByOwnerId(ownerId: staffId) .getAccountsByOwnerId(ownerId: staffId)
.execute(); .execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
return BankAccountAdapter.fromPrimitives( return BankAccountAdapter.fromPrimitives(
id: account.id, id: account.id,
userId: account.ownerId, userId: account.ownerId,
bankName: account.bank, bankName: account.bank,
accountNumber: account.accountNumber, accountNumber: account.accountNumber,
last4: account.last4, last4: account.last4,
sortCode: account.routeNumber, sortCode: account.routeNumber,
type: account.type is Known<AccountType> ? (account.type as Known<AccountType>).value.name : null, type: account.type is Known<AccountType> ? (account.type as Known<AccountType>).value.name : null,
isPrimary: account.isPrimary, isPrimary: account.isPrimary,
); );
}).toList(); }).toList();
});
} }
@override @override
Future<void> addAccount(BankAccount account) async { Future<void> addAccount(BankAccount account) async {
final String staffId = _getStaffId(); return executeProtected(() async {
final String staffId = _getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
existingAccounts = await dataConnect existingAccounts = await dataConnect
.getAccountsByOwnerId(ownerId: staffId) .getAccountsByOwnerId(ownerId: staffId)
.execute(); .execute();
final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty; final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty;
final bool isPrimary = !hasAccounts; final bool isPrimary = !hasAccounts;
await dataConnect.createAccount( await dataConnect.createAccount(
bank: account.bankName, bank: account.bankName,
type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)), type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)),
last4: _safeLast4(account.last4, account.accountNumber), last4: _safeLast4(account.last4, account.accountNumber),
ownerId: staffId, ownerId: staffId,
) )
.isPrimary(isPrimary) .isPrimary(isPrimary)
.accountNumber(account.accountNumber) .accountNumber(account.accountNumber)
.routeNumber(account.sortCode) .routeNumber(account.sortCode)
.execute(); .execute();
});
} }
/// Helper to get the logged-in staff ID. /// Helper to get the logged-in staff ID.
String _getStaffId() { String _getStaffId() {
final auth.User? user = firebaseAuth.currentUser; final auth.User? user = firebaseAuth.currentUser;
if (user == null) { if (user == null) {
throw Exception('User not authenticated'); throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
} }
final String? staffId = StaffSessionStore.instance.session?.staff?.id; final String? staffId = StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) { 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; return staffId;
} }

View File

@@ -48,18 +48,14 @@ class BankAccountPage extends StatelessWidget {
bloc: cubit, bloc: cubit,
listener: (BuildContext context, BankAccountState state) { listener: (BuildContext context, BankAccountState state) {
if (state.status == BankAccountStatus.accountAdded) { if (state.status == BankAccountStatus.accountAdded) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text( message: strings.account_added_success,
strings.account_added_success, type: UiSnackbarType.success,
style: UiTypography.body2r.textPrimary, margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
),
backgroundColor: UiColors.tagSuccess,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
); );
} }
// Error is already shown on the page itself (lines 73-85), no need for snackbar
}, },
builder: (BuildContext context, BankAccountState state) { builder: (BuildContext context, BankAccountState state) {
if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) { if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) {
@@ -67,7 +63,18 @@ class BankAccountPage extends StatelessWidget {
} }
if (state.status == BankAccountStatus.error) { 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( return Column(

View File

@@ -19,6 +19,25 @@ class _AddAccountFormState extends State<AddAccountForm> {
final TextEditingController _routingController = TextEditingController(); final TextEditingController _routingController = TextEditingController();
final TextEditingController _accountController = TextEditingController(); final TextEditingController _accountController = TextEditingController();
String _selectedType = 'CHECKING'; 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 @override
void dispose() { void dispose() {
@@ -96,14 +115,16 @@ class _AddAccountFormState extends State<AddAccountForm> {
Expanded( Expanded(
child: UiButton.primary( child: UiButton.primary(
text: widget.strings.save, text: widget.strings.save,
onPressed: () { onPressed: _isFormValid
widget.onSubmit( ? () {
_bankNameController.text, widget.onSubmit(
_routingController.text, _bankNameController.text.trim(),
_accountController.text, _routingController.text.trim(),
_selectedType, _accountController.text.trim(),
); _selectedType,
}, );
}
: null,
), ),
), ),
], ],

View File

@@ -9,7 +9,9 @@ import 'package:krow_core/core.dart';
import '../../domain/repositories/time_card_repository.dart'; import '../../domain/repositories/time_card_repository.dart';
/// Implementation of [TimeCardRepository] using Firebase Data Connect. /// Implementation of [TimeCardRepository] using Firebase Data Connect.
class TimeCardRepositoryImpl implements TimeCardRepository { class TimeCardRepositoryImpl
with dc.DataErrorHandler
implements TimeCardRepository {
final dc.ExampleConnector _dataConnect; final dc.ExampleConnector _dataConnect;
final firebase.FirebaseAuth _firebaseAuth; final firebase.FirebaseAuth _firebaseAuth;
@@ -22,57 +24,62 @@ class TimeCardRepositoryImpl implements TimeCardRepository {
Future<String> _getStaffId() async { Future<String> _getStaffId() async {
final firebase.User? user = _firebaseAuth.currentUser; 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<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result = final fdc.QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute(); await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) { 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; return result.data.staffs.first.id;
} }
@override @override
Future<List<TimeCard>> getTimeCards(DateTime month) async { Future<List<TimeCard>> getTimeCards(DateTime month) async {
final String staffId = await _getStaffId(); return executeProtected(() async {
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now. final String staffId = await _getStaffId();
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result = // Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute(); final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute();
return result.data.applications return result.data.applications
.where((dc.GetApplicationsByStaffIdApplications app) { .where((dc.GetApplicationsByStaffIdApplications app) {
final DateTime? shiftDate = app.shift.date == null final DateTime? shiftDate = app.shift.date == null
? null ? null
: DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); : DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
if (shiftDate == null) return false; if (shiftDate == null) return false;
return shiftDate.year == month.year && shiftDate.month == month.month; return shiftDate.year == month.year && shiftDate.month == month.month;
}) })
.map((dc.GetApplicationsByStaffIdApplications app) { .map((dc.GetApplicationsByStaffIdApplications app) {
final DateTime shiftDate = final DateTime shiftDate =
DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime()); DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? ''; final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? '';
final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? ''; final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? '';
// Prefer shiftRole values for pay/hours // Prefer shiftRole values for pay/hours
final double hours = app.shiftRole.hours ?? 0.0; final double hours = app.shiftRole.hours ?? 0.0;
final double rate = app.shiftRole.role.costPerHour; final double rate = app.shiftRole.role.costPerHour;
final double pay = app.shiftRole.totalValue ?? 0.0; final double pay = app.shiftRole.totalValue ?? 0.0;
return TimeCardAdapter.fromPrimitives( return TimeCardAdapter.fromPrimitives(
id: app.id, id: app.id,
shiftTitle: app.shift.title, shiftTitle: app.shift.title,
clientName: app.shift.order.business.businessName, clientName: app.shift.order.business.businessName,
date: shiftDate, date: shiftDate,
startTime: startTime, startTime: startTime,
endTime: endTime, endTime: endTime,
totalHours: hours, totalHours: hours,
hourlyRate: rate, hourlyRate: rate,
totalPay: pay, totalPay: pay,
status: app.status.stringValue, status: app.status.stringValue,
location: app.shift.location, location: app.shift.location,
); );
}) })
.toList(); .toList();
});
} }
String? _formatTime(fdc.Timestamp? timestamp) { String? _formatTime(fdc.Timestamp? timestamp) {

View File

@@ -27,6 +27,7 @@ class _TimeCardPageState extends State<TimeCardPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Translations.of(context);
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: Scaffold( child: Scaffold(
@@ -47,12 +48,30 @@ class _TimeCardPageState extends State<TimeCardPage> {
child: Container(color: UiColors.border, height: 1.0), child: Container(color: UiColors.border, height: 1.0),
), ),
), ),
body: BlocBuilder<TimeCardBloc, TimeCardState>( body: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (context, state) {
if (state is TimeCardError) {
UiSnackbar.show(
context,
message: translateErrorKey(state.message),
type: UiSnackbarType.error,
);
}
},
builder: (context, state) { builder: (context, state) {
if (state is TimeCardLoading) { if (state is TimeCardLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} else if (state is TimeCardError) { } 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) { } else if (state is TimeCardLoaded) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(

View File

@@ -42,8 +42,11 @@ class AttirePage extends StatelessWidget {
body: BlocConsumer<AttireCubit, AttireState>( body: BlocConsumer<AttireCubit, AttireState>(
listener: (BuildContext context, AttireState state) { listener: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.failure) { if (state.status == AttireStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar(content: Text(state.errorMessage ?? 'Error')), context,
message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16),
); );
} }
if (state.status == AttireStatus.saved) { if (state.status == AttireStatus.saved) {

View File

@@ -7,6 +7,7 @@ import '../../domain/repositories/emergency_contact_repository_interface.dart';
/// ///
/// This repository delegates data operations to Firebase Data Connect. /// This repository delegates data operations to Firebase Data Connect.
class EmergencyContactRepositoryImpl class EmergencyContactRepositoryImpl
with dc.DataErrorHandler
implements EmergencyContactRepositoryInterface { implements EmergencyContactRepositoryInterface {
final dc.ExampleConnector _dataConnect; final dc.ExampleConnector _dataConnect;
final FirebaseAuth _firebaseAuth; final FirebaseAuth _firebaseAuth;
@@ -20,64 +21,81 @@ class EmergencyContactRepositoryImpl
Future<String> _getStaffId() async { Future<String> _getStaffId() async {
final user = _firebaseAuth.currentUser; final user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated'); if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final result = final result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute(); await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) { 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; return result.data.staffs.first.id;
} }
@override @override
Future<List<EmergencyContact>> getContacts() async { Future<List<EmergencyContact>> getContacts() async {
final staffId = await _getStaffId(); return executeProtected(() async {
final result = final staffId = await _getStaffId();
await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute(); final result = await _dataConnect
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
return result.data.emergencyContacts.map((dto) { return result.data.emergencyContacts.map((dto) {
return EmergencyContactAdapter.fromPrimitives( return EmergencyContactAdapter.fromPrimitives(
id: dto.id, id: dto.id,
name: dto.name, name: dto.name,
phone: dto.phone, phone: dto.phone,
relationship: dto.relationship.stringValue, relationship: dto.relationship.stringValue,
); );
}).toList(); }).toList();
});
} }
@override @override
Future<void> saveContacts(List<EmergencyContact> contacts) async { Future<void> saveContacts(List<EmergencyContact> contacts) async {
final staffId = await _getStaffId(); return executeProtected(() async {
final staffId = await _getStaffId();
// 1. Get existing to delete // 1. Get existing to delete
final existingResult = final existingResult = await _dataConnect
await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute(); .getEmergencyContactsByStaffId(staffId: staffId)
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(); .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();
}));
});
} }
} }

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_info_banner.dart';
import '../widgets/emergency_contact_save_button.dart'; import '../widgets/emergency_contact_save_button.dart';
/// The Staff Emergency Contact screen. /// The Staff Emergency Contact screen.
/// ///
/// This screen allows staff to manage their emergency contacts during onboarding. /// This screen allows staff to manage their emergency contacts during onboarding.
@@ -19,6 +19,7 @@ class EmergencyContactScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Translations.of(context); // Force rebuild on locale change
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@@ -38,10 +39,16 @@ class EmergencyContactScreen extends StatelessWidget {
body: BlocProvider( body: BlocProvider(
create: (context) => Modular.get<EmergencyContactBloc>(), create: (context) => Modular.get<EmergencyContactBloc>(),
child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>( child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>(
listener: (context, state) { listener: (context, state) {
if (state.status == EmergencyContactStatus.failure) { if (state.status == EmergencyContactStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar(content: Text(state.errorMessage ?? 'An error occurred')), context,
message: state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16),
); );
} }
}, },

View File

@@ -16,14 +16,11 @@ class EmergencyContactSaveButton extends StatelessWidget {
listenWhen: (previous, current) => previous.status != current.status, listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) { listener: (context, state) {
if (state.status == EmergencyContactStatus.saved) { if (state.status == EmergencyContactStatus.saved) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text( message: 'Emergency contacts saved successfully',
'Emergency contacts saved successfully', type: UiSnackbarType.success,
style: UiTypography.body2r.textPrimary, margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16),
),
backgroundColor: UiColors.iconSuccess,
),
); );
} }
}, },

View File

@@ -2,13 +2,17 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import '../../domain/repositories/experience_repository_interface.dart'; import '../../domain/repositories/experience_repository_interface.dart';
import 'package:krow_domain/krow_domain.dart';
/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect. /// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect.
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { class ExperienceRepositoryImpl
with dc.DataErrorHandler
implements ExperienceRepositoryInterface {
final dc.ExampleConnector _dataConnect; final dc.ExampleConnector _dataConnect;
// ignore: unused_field // ignore: unused_field
final FirebaseAuth _firebaseAuth; final FirebaseAuth _firebaseAuth;
/// Creates a [ExperienceRepositoryImpl] using Da a Connect and Auth. /// Creates a [ExperienceRepositoryImpl] using Data Connect and Auth.
ExperienceRepositoryImpl({ ExperienceRepositoryImpl({
required dc.ExampleConnector dataConnect, required dc.ExampleConnector dataConnect,
required FirebaseAuth firebaseAuth, required FirebaseAuth firebaseAuth,
@@ -17,26 +21,33 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
Future<dc.GetStaffByUserIdStaffs> _getStaff() async { Future<dc.GetStaffByUserIdStaffs> _getStaff() async {
final user = _firebaseAuth.currentUser; final user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated'); if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final result = final result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute(); await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) { if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found'); throw const ServerException(technicalMessage: 'Staff profile not found');
} }
return result.data.staffs.first; return result.data.staffs.first;
} }
@override @override
Future<List<String>> getIndustries() async { Future<List<String>> getIndustries() async {
final staff = await _getStaff(); return executeProtected(() async {
return staff.industries ?? []; final staff = await _getStaff();
return staff.industries ?? [];
});
} }
@override @override
Future<List<String>> getSkills() async { Future<List<String>> getSkills() async {
final staff = await _getStaff(); return executeProtected(() async {
return staff.skills ?? []; final staff = await _getStaff();
return staff.skills ?? [];
});
} }
@override @override
@@ -44,15 +55,13 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
List<String> industries, List<String> industries,
List<String> skills, List<String> skills,
) async { ) async {
try { return executeProtected(() async {
final staff = await _getStaff(); final staff = await _getStaff();
await _dataConnect await _dataConnect
.updateStaff(id: staff.id) .updateStaff(id: staff.id)
.industries(industries) .industries(industries)
.skills(skills) .skills(skills)
.execute(); .execute();
} catch (e) { });
throw Exception('Failed to save experience: $e');
}
} }
} }

View File

@@ -46,7 +46,7 @@ class ExperiencePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = t.staff.onboarding.experience; final i18n = Translations.of(context).staff.onboarding.experience;
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
@@ -58,13 +58,21 @@ class ExperiencePage extends StatelessWidget {
child: BlocConsumer<ExperienceBloc, ExperienceState>( child: BlocConsumer<ExperienceBloc, ExperienceState>(
listener: (context, state) { listener: (context, state) {
if (state.status == ExperienceStatus.success) { if (state.status == ExperienceStatus.success) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
const SnackBar(content: Text('Experience saved successfully')), context,
message: 'Experience saved successfully',
type: UiSnackbarType.success,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
); );
Modular.to.pop(); Modular.to.pop();
} else if (state.status == ExperienceStatus.failure) { } else if (state.status == ExperienceStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar(content: Text(state.errorMessage ?? 'An error occurred')), context,
message: state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
); );
} }
}, },

View File

@@ -13,7 +13,9 @@ import '../../domain/repositories/personal_info_repository_interface.dart';
/// - Delegating all data access to the data_connect layer /// - Delegating all data access to the data_connect layer
/// - Mapping between data_connect DTOs and domain entities /// - Mapping between data_connect DTOs and domain entities
/// - Containing no business logic /// - Containing no business logic
class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface { class PersonalInfoRepositoryImpl
with DataErrorHandler
implements PersonalInfoRepositoryInterface {
/// Creates a [PersonalInfoRepositoryImpl]. /// Creates a [PersonalInfoRepositoryImpl].
/// ///
@@ -28,58 +30,63 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
@override @override
Future<Staff> getStaffProfile() async { Future<Staff> getStaffProfile() async {
final firebase_auth.User? user = _firebaseAuth.currentUser; return executeProtected(() async {
if (user == null) { final firebase_auth.User? user = _firebaseAuth.currentUser;
throw Exception('User not authenticated'); if (user == null) {
} throw NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
// Query staff data from Firebase Data Connect // Query staff data from Firebase Data Connect
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result = final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute(); await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) { if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found for User ID: ${user.uid}'); 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 // Map from data_connect DTO to domain entity
return _mapToStaffEntity(rawStaff); return _mapToStaffEntity(rawStaff);
});
} }
@override @override
Future<Staff> updateStaffProfile({required String staffId, required Map<String, dynamic> data}) async { Future<Staff> updateStaffProfile({required String staffId, required Map<String, dynamic> data}) async {
// Start building the update mutation return executeProtected(() async {
UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId); // Start building the update mutation
UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId);
// Apply updates from map if present // Apply updates from map if present
if (data.containsKey('name')) { if (data.containsKey('name')) {
updateBuilder = updateBuilder.fullName(data['name'] as String); updateBuilder = updateBuilder.fullName(data['name'] as String);
} }
if (data.containsKey('email')) { if (data.containsKey('email')) {
updateBuilder = updateBuilder.email(data['email'] as String); updateBuilder = updateBuilder.email(data['email'] as String);
} }
if (data.containsKey('phone')) { if (data.containsKey('phone')) {
updateBuilder = updateBuilder.phone(data['phone'] as String?); updateBuilder = updateBuilder.phone(data['phone'] as String?);
} }
if (data.containsKey('avatar')) { if (data.containsKey('avatar')) {
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?); updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
} }
if (data.containsKey('preferredLocations')) { if (data.containsKey('preferredLocations')) {
// After schema update and SDK regeneration, preferredLocations accepts List<String> // After schema update and SDK regeneration, preferredLocations accepts List<String>
updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List<String>); updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List<String>);
} }
// Execute the update // Execute the update
final OperationResult<UpdateStaffData, UpdateStaffVariables> result = final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
await updateBuilder.execute(); await updateBuilder.execute();
if (result.data.staff_update == null) { if (result.data.staff_update == null) {
throw Exception('Failed to update staff profile'); throw const ServerException(technicalMessage: 'Failed to update staff profile');
} }
// Fetch the updated staff profile to return complete entity // Fetch the updated staff profile to return complete entity
return getStaffProfile(); return getStaffProfile();
});
} }
@override @override

View File

@@ -14,7 +14,7 @@ import 'personal_info_state.dart';
/// during onboarding or profile editing. It delegates business logic to /// during onboarding or profile editing. It delegates business logic to
/// use cases following Clean Architecture principles. /// use cases following Clean Architecture principles.
class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState> class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
with BlocErrorHandler<PersonalInfoState> with BlocErrorHandler<PersonalInfoState>, SafeBloc<PersonalInfoEvent, PersonalInfoState>
implements Disposable { implements Disposable {
/// Creates a [PersonalInfoBloc]. /// Creates a [PersonalInfoBloc].
/// ///
@@ -54,8 +54,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
'phone': staff.phone, 'phone': staff.phone,
'preferredLocations': 'preferredLocations':
staff.address != null staff.address != null
? <String?>[staff.address] ? <String>[staff.address!]
: <dynamic>[], // TODO: Map correctly when Staff entity supports list : <String>[], // TODO: Map correctly when Staff entity supports list
'avatar': staff.avatar, 'avatar': staff.avatar,
}; };
@@ -109,8 +109,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
'phone': updatedStaff.phone, 'phone': updatedStaff.phone,
'preferredLocations': 'preferredLocations':
updatedStaff.address != null updatedStaff.address != null
? <String?>[updatedStaff.address] ? <String>[updatedStaff.address!]
: <dynamic>[], : <String>[],
'avatar': updatedStaff.avatar, 'avatar': updatedStaff.avatar,
}; };

View File

@@ -22,26 +22,25 @@ class PersonalInfoPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info; final i18n = Translations.of(context).staff.onboarding.personal_info;
return BlocProvider<PersonalInfoBloc>( return BlocProvider<PersonalInfoBloc>(
create: (BuildContext context) => Modular.get<PersonalInfoBloc>(), create: (BuildContext context) => Modular.get<PersonalInfoBloc>(),
child: BlocListener<PersonalInfoBloc, PersonalInfoState>( child: BlocListener<PersonalInfoBloc, PersonalInfoState>(
listener: (BuildContext context, PersonalInfoState state) { listener: (BuildContext context, PersonalInfoState state) {
if (state.status == PersonalInfoStatus.saved) { if (state.status == PersonalInfoStatus.saved) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text(i18n.save_success), message: i18n.save_success,
duration: const Duration(seconds: 2), type: UiSnackbarType.success,
),
); );
Modular.to.pop(); Modular.to.pop();
} else if (state.status == PersonalInfoStatus.error) { } else if (state.status == PersonalInfoStatus.error) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text(state.errorMessage ?? 'An error occurred'), message: state.errorMessage != null
backgroundColor: UiColors.destructive, ? translateErrorKey(state.errorMessage!)
duration: const Duration(seconds: 3), : 'An error occurred',
), type: UiSnackbarType.error,
); );
} }
}, },

View File

@@ -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: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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -136,20 +137,18 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
} }
if (state is ShiftActionSuccess) { if (state is ShiftActionSuccess) {
_isApplying = false; _isApplying = false;
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text(state.message), message: state.message,
backgroundColor: UiColors.success, type: UiSnackbarType.success,
),
); );
Modular.to.toShifts(selectedDate: state.shiftDate); Modular.to.toShifts(selectedDate: state.shiftDate);
} else if (state is ShiftDetailsError) { } else if (state is ShiftDetailsError) {
if (_isApplying || widget.shift == null) { if (_isApplying || widget.shift == null) {
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: Text(state.message), message: translateErrorKey(state.message),
backgroundColor: UiColors.destructive, type: UiSnackbarType.error,
),
); );
} }
_isApplying = false; _isApplying = false;
@@ -170,9 +169,10 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
displayShift = widget.shift; displayShift = widget.shift;
} }
final i18n = Translations.of(context).staff_shifts.shift_details;
if (displayShift == null) { if (displayShift == null) {
return const Scaffold( return Scaffold(
body: Center(child: Text("Shift not found")), body: Center(child: Text(Translations.of(context).staff_shifts.list.no_shifts)),
); );
} }
@@ -202,7 +202,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"VENDOR", i18n.vendor,
style: UiTypography.titleUppercase4b.textSecondary, style: UiTypography.titleUppercase4b.textSecondary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
@@ -245,7 +245,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"SHIFT DATE", i18n.shift_date,
style: UiTypography.titleUppercase4b.textSecondary, style: UiTypography.titleUppercase4b.textSecondary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
@@ -284,7 +284,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
Text( Text(
"$openSlots slots remaining", i18n.slots_remaining(count: openSlots),
style: UiTypography.footnote1m.textSuccess, style: UiTypography.footnote1m.textSuccess,
), ),
], ],
@@ -298,14 +298,14 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
children: [ children: [
Expanded( Expanded(
child: _buildTimeBox( child: _buildTimeBox(
"START TIME", i18n.start_time,
displayShift.startTime, displayShift.startTime,
), ),
), ),
const SizedBox(width: UiConstants.space4), const SizedBox(width: UiConstants.space4),
Expanded( Expanded(
child: _buildTimeBox( child: _buildTimeBox(
"END TIME", i18n.end_time,
displayShift.endTime, displayShift.endTime,
), ),
), ),
@@ -320,15 +320,15 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
child: _buildStatCard( child: _buildStatCard(
UiIcons.dollar, UiIcons.dollar,
"\$${displayShift.hourlyRate.toStringAsFixed(0)}/hr", "\$${displayShift.hourlyRate.toStringAsFixed(0)}/hr",
"Base Rate", i18n.base_rate,
), ),
), ),
const SizedBox(width: UiConstants.space4), const SizedBox(width: UiConstants.space4),
Expanded( Expanded(
child: _buildStatCard( child: _buildStatCard(
UiIcons.clock, UiIcons.clock,
"${duration.toInt()} hours", i18n.hours_label(count: duration.toInt()),
"Duration", i18n.duration,
), ),
), ),
const SizedBox(width: UiConstants.space4), const SizedBox(width: UiConstants.space4),
@@ -336,7 +336,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
child: _buildStatCard( child: _buildStatCard(
UiIcons.wallet, UiIcons.wallet,
"\$${estimatedTotal.toStringAsFixed(0)}", "\$${estimatedTotal.toStringAsFixed(0)}",
"Est. Total", i18n.est_total,
), ),
), ),
], ],
@@ -348,7 +348,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"LOCATION", i18n.location,
style: UiTypography.titleUppercase4b.textSecondary, style: UiTypography.titleUppercase4b.textSecondary,
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
@@ -396,7 +396,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
UiIcons.arrowRight, UiIcons.arrowRight,
size: 16, size: 16,
), ),
label: const Text("Open in Maps"), label: Text(i18n.open_in_maps),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: UiColors.primary, foregroundColor: UiColors.primary,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -412,7 +412,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
// Description / Instructions // Description / Instructions
if ((displayShift.description ?? '').isNotEmpty) ...[ if ((displayShift.description ?? '').isNotEmpty) ...[
Text( Text(
"JOB DESCRIPTION", i18n.job_description,
style: UiTypography.titleUppercase4b.textSecondary, style: UiTypography.titleUppercase4b.textSecondary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
@@ -460,15 +460,16 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
BuildContext context, BuildContext context,
Shift shift, Shift shift,
) { ) {
final i18n = Translations.of(context).staff_shifts.shift_details.book_dialog;
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Book Shift'), title: Text(i18n.title),
content: const Text('Do you want to instantly book this shift?'), content: Text(i18n.message),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.pop(),
child: const Text('Cancel'), child: Text(Translations.of(context).common.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -485,7 +486,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: UiColors.success, 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<ShiftDetailsPage> {
} }
void _declineShift(BuildContext context, String id) { void _declineShift(BuildContext context, String id) {
final i18n = Translations.of(context).staff_shifts.shift_details.decline_dialog;
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Decline Shift'), title: Text(i18n.title),
content: const Text( content: Text(i18n.message),
'Are you sure you want to decline this shift? It will be hidden from your available jobs.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.pop(),
child: const Text('Cancel'), child: Text(Translations.of(context).common.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -514,7 +514,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: UiColors.destructive, 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<ShiftDetailsPage> {
if (_actionDialogOpen) return; if (_actionDialogOpen) return;
_actionDialogOpen = true; _actionDialogOpen = true;
_isApplying = true; _isApplying = true;
final i18n = Translations.of(context).staff_shifts.shift_details.applying_dialog;
showDialog( showDialog(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Applying'), title: Text(i18n.title),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -576,6 +577,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
Widget _buildBottomButton(Shift shift, BuildContext context) { Widget _buildBottomButton(Shift shift, BuildContext context) {
final String status = shift.status ?? 'open'; final String status = shift.status ?? 'open';
final i18n = Translations.of(context).staff_shifts.shift_details;
if (status == 'confirmed') { if (status == 'confirmed') {
return Row( return Row(
children: [ children: [
@@ -591,7 +593,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
elevation: 0, elevation: 0,
), ),
child: Text("CANCEL SHIFT", style: UiTypography.body2b.white), child: Text(i18n.cancel_shift, style: UiTypography.body2b.white),
), ),
), ),
const SizedBox(width: UiConstants.space4), const SizedBox(width: UiConstants.space4),
@@ -607,7 +609,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
elevation: 0, 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<ShiftDetailsPage> {
borderRadius: BorderRadius.circular(UiConstants.radiusBase), 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), const SizedBox(width: UiConstants.space4),
@@ -644,7 +646,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
elevation: 0, 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<ShiftDetailsPage> {
borderRadius: BorderRadius.circular(UiConstants.radiusBase), 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), const SizedBox(width: UiConstants.space4),
@@ -681,7 +683,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
), ),
elevation: 0, 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<ShiftDetailsPage> {
} }
void _openCancelDialog(BuildContext context) { void _openCancelDialog(BuildContext context) {
final i18n = Translations.of(context).staff_shifts.shift_details.cancel_dialog;
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Cancel Shift'), title: Text(i18n.title),
content: const Text('Are you sure you want to cancel this shift?'), content: Text(i18n.message),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.pop(),
child: const Text('No'), child: Text(Translations.of(context).common.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -712,7 +715,7 @@ class _ShiftDetailsPageState extends State<ShiftDetailsPage> {
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: UiColors.destructive, foregroundColor: UiColors.destructive,
), ),
child: const Text('Yes, cancel it'), child: Text(Translations.of(context).common.ok),
), ),
], ],
), ),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/shifts/shifts_bloc.dart'; import '../blocs/shifts/shifts_bloc.dart';
import '../widgets/tabs/my_shifts_tab.dart'; import '../widgets/tabs/my_shifts_tab.dart';
@@ -65,9 +66,19 @@ class _ShiftsPageState extends State<ShiftsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Translations.of(context);
return BlocProvider.value( return BlocProvider.value(
value: _bloc, value: _bloc,
child: BlocBuilder<ShiftsBloc, ShiftsState>( child: BlocConsumer<ShiftsBloc, ShiftsState>(
listener: (context, state) {
if (state is ShiftsError) {
UiSnackbar.show(
context,
message: translateErrorKey(state.message),
type: UiSnackbarType.error,
);
}
},
builder: (context, state) { builder: (context, state) {
final bool baseLoaded = state is ShiftsLoaded; final bool baseLoaded = state is ShiftsLoaded;
final List<Shift> myShifts = (state is ShiftsLoaded) final List<Shift> myShifts = (state is ShiftsLoaded)
@@ -123,7 +134,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
spacing: UiConstants.space4, spacing: UiConstants.space4,
children: [ children: [
Text( Text(
"Shifts", t.staff_shifts.title,
style: UiTypography.display1b.white, style: UiTypography.display1b.white,
), ),
@@ -132,7 +143,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
children: [ children: [
_buildTab( _buildTab(
"myshifts", "myshifts",
"My Shifts", t.staff_shifts.tabs.my_shifts,
UiIcons.calendar, UiIcons.calendar,
myShifts.length, myShifts.length,
showCount: myShiftsLoaded, showCount: myShiftsLoaded,
@@ -141,7 +152,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
_buildTab( _buildTab(
"find", "find",
"Find Shifts", t.staff_shifts.tabs.find_work,
UiIcons.search, UiIcons.search,
availableJobs.length, availableJobs.length,
showCount: availableLoaded, showCount: availableLoaded,
@@ -150,7 +161,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
_buildTab( _buildTab(
"history", "history",
"History", t.staff_shifts.tabs.history,
UiIcons.clock, UiIcons.clock,
historyShifts.length, historyShifts.length,
showCount: historyLoaded, showCount: historyLoaded,
@@ -166,7 +177,26 @@ class _ShiftsPageState extends State<ShiftsPage> {
Expanded( Expanded(
child: state is ShiftsLoading child: state is ShiftsLoading
? const Center(child: CircularProgressIndicator()) ? 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, myShifts,
pendingAssignments, pendingAssignments,
cancelledShifts, cancelledShifts,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../blocs/shifts/shifts_bloc.dart'; import '../../blocs/shifts/shifts_bloc.dart';
import '../my_shift_card.dart'; import '../my_shift_card.dart';
@@ -115,11 +116,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
context.read<ShiftsBloc>().add(AcceptShiftEvent(id)); context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: const Text('Shift confirmed!'), message: 'Shift confirmed!',
backgroundColor: UiColors.success, type: UiSnackbarType.success,
),
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -149,11 +149,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
context.read<ShiftsBloc>().add(DeclineShiftEvent(id)); context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
ScaffoldMessenger.of(context).showSnackBar( UiSnackbar.show(
SnackBar( context,
content: const Text('Shift declined.'), message: 'Shift declined.',
backgroundColor: UiColors.destructive, type: UiSnackbarType.error,
),
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(

View File

@@ -36,6 +36,7 @@ class StaffMainBottomBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Translations.of(context);
// Staff App colors from design system // Staff App colors from design system
// Using primary (Blue) for active as per prototype // Using primary (Blue) for active as per prototype
const Color activeColor = UiColors.primary; const Color activeColor = UiColors.primary;

View File

@@ -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<T> handleDataOperation<T>(Future<T> 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<void> 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<FormW4Bloc, FormW4State>(
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`