• English
  • Architecture

    Primary–Secondary topology

    Salon POS uses a Primary–Secondary architecture. Every device — including the Primary itself — talks to a single local HTTPS API. Only the Primary owns the SQLite database and syncs to the cloud.

    ┌──────────────┐     HTTPS (LAN)      ┌──────────────┐
    │  Secondary 1 │ ──────────────────▶  │              │
    └──────────────┘                      │   Primary    │
    ┌──────────────┐     HTTPS (LAN)      │  (Shelf API) │     CloudSync    ┌───────────┐
    │  Secondary 2 │ ──────────────────▶  │   :8080      │ ──────────────▶  │  AWS RDS  │
    └──────────────┘                      │  + Drift DB  │                  │ PostgreSQL│
    ┌──────────────┐     HTTPS (LAN)      │              │                  └───────────┘
    │  Primary     │ ──────────────────▶  └──────────────┘
    │  (itself)    │
    └──────────────┘

    All devices use the same HTTP repository classes, targeting the same Shelf API. There is no branching logic in client code — switching roles is a base-URL change.

    Role state machine

    Defined in lib/core/device/device_role_provider.dart.

    StateDescriptionLocal DB
    primaryRuns Shelf server on :8080, owns Drift, handles all write operations, syncs to cloudFull Drift DB
    secondaryConnects to a Primary via HTTPS, all reads/writes through the Primary's APINone
    emergencyBufferPrimary unreachable; buffers writes to SyncBuffer table, replays on reconnectSmall buffer DB

    Transitions:

    • First launch defaults to primary
    • Manual promotion/demotion via Settings
    • emergencyBuffer auto-enters when the Primary is unreachable (3 failed pings → subnet scan for new Primary)
    • emergencyBuffer without a configured IP self-heals back to primary

    Bootstrap sequence

    lib/main.dart (~650 lines) constructs the entire dependency graph imperatively before calling runApp:

    1. Firebase init — best-effort; failure is non-fatal
    2. DB seedseedAllDemoData() seeds demo data directly
    3. Onboarding gate — reads onboarding_complete from app_settings
    4. TLS cert — generate-on-first-run Ed25519 self-signed cert via DeviceCertService
    5. LAN authLanAuthService: challenge/response + session tokens + roster verification
    6. ObservabilityObsLogger with ring buffer + pending event queue + cloud/primary-forward sinks
    7. Local HTTPS serverLocalServer (Shelf): Primary serves LAN API; Secondaries forward through it
    8. Cloud syncCloudSyncWorker + CloudSyncScheduler + BookingOutboxWorker
    9. PushPushService with FCM nudge polling
    10. HeartbeatHeartbeatScheduler: Primary telemetry
    11. RosterRosterService: periodic cloud pull of device roster
    12. ContainerProviderContainer with ~20 overrides for all singletons
    13. RunrunApp(UncontrolledProviderScope(container: container, child: SalonPosApp()))

    LAN security model

    TLS pinning (lib/core/network/tls_pinning.dart)

    • SHA-256 fingerprint pinning is never disabled
    • Missing fingerprints cause connection failure, not a downgrade
    • Each device generates a self-signed Ed25519 cert on first run via DeviceCertService

    Device identity (lib/core/device/)

    • device_id_provider.dart — persistent UUID stored in SharedPreferences
    • device_keypair.dart — Ed25519 keypair stored in flutter_secure_storage
    • Used for LAN authentication challenge-response signatures

    LAN authentication (lib/core/network/lan_auth_service.dart)

    • Ed25519 challenge-response protocol between devices
    • Session tokens minted and validated per-request
    • lan_auth_middleware.dart — Shelf middleware for Authorization: Bearer <token>
    • lan_session_interceptor.dart — Dio interceptor attaching tokens to client requests

    Device discovery

    • primary_locator.dart — subnet scan to find a Primary after handover
    • candidate_verifier.dart — verifies a discovered device is a legitimate Primary
    • roster_bootstrap.dart — bootstraps the device roster from LAN discovery

    Shelf API server

    lib/core/network/local_server.dart mounts 28+ domain handlers on a Shelf Router:

    HandlerDomain
    TicketHandlerCRUD for tickets, services, products
    CustomerHandlerCustomer profiles, memberships
    AppointmentHandlerAppointments, recurrence, booking requests
    StaffHandlerStaff profiles, schedules, time clocks
    SessionHandlerBusiness session open/close, cash reconciliation
    ReportHandlerAll reporting queries
    GiftCardHandlerGift card sale, redemption, balance
    LoyaltyHandlerLoyalty points, tier management
    ServicePackageHandlerPackage/value-card management
    CardTransactionHandlerCodePay terminal transactions
    PaymentHandlerPayment processing
    CheckoutHandlerCheckout operations
    WaitlistHandlerWalk-in queue
    ResourceHandlerRoom/chair/equipment
    BookingRequestsHandlerOnline booking requests
    NotificationsHandlerIn-app notifications
    CloudConfigHandlerCloud feature flags, booking slug
    AdminHandlerAdministrative operations
    ObsHandlerObservability command forwarding
    StoreMediaHandlerStore images/media
    LanHandlerLAN discovery, roster
    InventoryHandlerInventory management
    CouponHandlerCoupon/discount management
    SettingsHandlerApp/stores settings
    OnboardingHandlerOnboarding flow data

    Each handler receives a Shelf.Request and returns a Shelf.Response. Handlers are fully testable by mounting them on a test Shelf server.

    Dio HTTP client

    lib/core/network/api_client.dart builds a TLS-pinned HTTPS Dio instance:

    • Primary mode: targets https://<local-ip>:8080
    • Secondary mode: targets https://<primary-ip>:8080
    • EmergencyBuffer interceptor: catches connectivity failures, buffers writes to SyncBuffer, flips role to emergencyBuffer
    • kNoEmergencyBuffer flag on Dio extras prevents background-timer triggers from buffering

    EmergencyBuffer

    lib/core/sync/sync_buffer_service.dart:

    1. When the Primary is unreachable, EmergencyBufferInterceptor stores the request in SyncBuffer table
    2. Every 30 seconds, SyncBufferService pings the Primary
    3. On reconnect, replays all buffered operations in order
    4. After 3 consecutive failed pings, triggers a subnet scan for a new Primary

    Offline-first design

    • LAN traffic is the only critical path — cloud sync, telemetry, onboarding, and heartbeats all timeout, retry, and degrade gracefully
    • Cloud sync is fire-and-forgetCloudSyncWorker drains the outbox asynchronously
    • No blocking network calls on the UI thread — all repositories use async Dio calls
    • Secondary devices have no database — they only buffer during emergency mode

    UI shell

    lib/app.dart:

    • SalonPosApp — wraps ScreenUtilInitMaterialApp.router with EN/ZH localization, light/dark themes
    • MainScaffold — persistent left NavigationRail with 8 destinations (Dashboard, Check-in, Tickets, Customers, Appointments, Reports, Card Transactions, Settings) plus RBAC-gated visibility
    • BrokenSecondaryScreen — full-screen fallback when role is secondary but no Primary IP is configured

    Router

    lib/core/router/app_router.dart — ~35 GoRouter routes:

    • ShellRoute wrapping MainScaffold
    • Auth guard via onboardingRedirect() — respects device role, onboarding state, staff session
    • _SessionListenable bridges Riverpod watches to GoRouter's refreshListenable

    Key source files

    AreaFiles
    Bootstraplib/main.dart
    UI shelllib/app.dart
    Databaselib/core/database/app_database.dart, lib/core/database/tables.dart
    LAN serverlib/core/network/local_server.dart
    API clientlib/core/network/api_client.dart
    LAN authlib/core/network/lan_auth_service.dart, lan_auth_middleware.dart
    TLS/pinninglib/core/network/tls_pinning.dart, device_cert_service.dart
    Device rolelib/core/device/device_role_provider.dart, device_id_provider.dart, device_keypair.dart
    Routerlib/core/router/app_router.dart
    EmergencyBufferlib/core/sync/sync_buffer_service.dart
    Cloud sync workerlib/core/sync/cloud_sync_worker.dart
    Themelib/core/theme/app_theme.dart