arch-atlas
This is the print-ready handoff document — use your browser's Print → Save as PDF to export it for the client.

Project handoff

Seaven

Flutter app — Clean Architecture (GetIt · GetX).

Stack
Flutter · GetIt · GetX · Clean Architecture

Authentication

Dual-token (customer + seller) email/password, Google and Apple sign-in, registration with email-OTP verification, and password recovery — all gated by a GoRouter redirect that also enforces profile setup and ToS consent.

Riverpod · GoRouter · Dio · SharedPreferences

API surface

MethodEndpointPurposeAuthStatus
POST/auth/customer/emailpass-verifiedAuthenticate as a customer with email + password and receive a session token (mandatory leg of the dual-login).no status
POST/auth/seller/emailpass-verifiedBest-effort seller login using the same credentials — used to detect multi-role users.no status
POST/auth/customer/google-mobileExchange a Google OAuth payload (id_token + access_token) for a customer session token.no status
POST/auth/apple/callbackExchange an Apple Sign-In identity payload for a customer session token.no status
POST/auth/customer/emailpass-verified/registerCreate a customer account; backend emails an OTP and does NOT issue a session yet.no status
POST/auth/customer/emailpass-verified/verifyVerify the emailed OTP and finally issue the customer session token.no status
POST/auth/customer/emailpass-verified/reset-passwordTrigger a password-reset email for the given address.no status
POST/auth/reset-password/verifyVerify the OTP from the reset email and receive a short-lived reset token.no status
POST/auth/customer/emailpass-verified/updateSet a new password using the short-lived reset token.no status
POST/store/change-password/emailpass-verifiedRotate the authenticated customer's password from the settings screen.no status
POST/auth/password-change/Legacy password-change endpoint kept on AuthApiService but no longer wired into any UI.no status
POST/auth/sign-in/emailLegacy single-token email/password sign-in — kept on the old AuthService for AuthRepositoryImpl compatibility, not used by the current Riverpod notifier.no status
POST/fcm/tokenSide-channel: upload the FCM push token for the active auth scope so the backend can target push to this device + role.no status

Presentation layer

Login Screen · features/shared/auth/login/presentation

Single screen offering email/phone + password, plus Google and Apple buttons. Listens to loginNotifierProvider for the per-method loading flags so each button can show its own spinner independently.

  1. User picks auth method (email or phone) via AuthMethodNotifier.
  2. Enters credentials → submits → ref.read(loginNotifierProvider.notifier).login(...).
  3. loginNotifierProvider streams state — isPasswordLoading toggles the submit button spinner.
  4. On state.isAuthenticated the GoRouter redirect fires and navigates to /home.
  5. On state.error a SnackBar surfaces the unwrapped exception message.
  • Three independent loading flags (password / google / apple) prevent cross-method spinner flicker.
  • Apple button is conditionally rendered on iOS only — Android shows Google only.
  • Google cancellation must not surface as an error — the notifier returns false but leaves state.error null.
lib/src/features/shared/auth/login/presentation/login.dart
lib/src/features/shared/auth/login/presentation/widgets/google_sign_in_button.dart
lib/src/features/shared/auth/login/presentation/widgets/apple_sign_in_button.dart
lib/src/features/shared/auth/login/presentation/widgets/email_or_phone.dart

Phone-based password login is part of the UI toggle but the backend currently only supports email — picking 'phone' falls back to email validation messages.

Register + OTP Screens · onboarding_register/presentation/pages

Two-step wizard pages: RegisterStepPage collects email + password and triggers registerNotifier; OtpStepPage receives the email via route extra and verifies the 6-digit code, after which the session is finally persisted.

  1. RegisterStepPage → registerNotifier.register(email, password).
  2. On success, wizard pushes OtpStepPage with state.extra = {email}.
  3. OtpStepPage submits the code → AuthApiService.verify → AuthStorageService.saveSessionOtp persists the token.
  4. GoRouter redirect bounces to /onboarding_register (or /onboarding_register_seller for pending sellers).
  • Hot-reload of OtpStepPage with no route extra crashes — defensive cast is intentional, dev-only failure.
  • Re-sending the OTP is rate-limited server-side; UI cools down the button for ~30s without consulting the backend.
  • If the user backgrounds the app between register and OTP, the partially-saved pendingRegistrationData (in AuthStorageService) keeps the wizard resumable.
lib/src/features/shared/onboarding_register/presentation/pages/register_step_page.dart
lib/src/features/shared/onboarding_register/presentation/pages/otp_step_page.dart
lib/src/features/shared/onboarding_register/presentation/onboarding_register_wizard.dart

The /register and /otp routes both point into the onboarding-register wizard rather than the legacy auth/register/register.dart screen, which is kept around but unrouted.

Password Recovery Screens · forget_password + change_password presentation

Four screens: ForgetPasswordScreen (request email), ResetPasswordOtpScreen (verify code), ResetPasswordScreen (set new), ChangePasswordScreen (authenticated rotate). Each consumes its dedicated Riverpod notifier and surfaces a single error toast on failure.

  1. ForgetPasswordScreen → forgotPasswordProvider.sendResetEmail.
  2. Navigation pushes ResetPasswordOtpScreen with email; on verify it pushes ResetPasswordScreen with {email, token}.
  3. ResetPasswordScreen submits new password and pops back to login.
  4. ChangePasswordScreen (settings entry) calls changePasswordProvider directly without OTP.
  • Reset-flow routes pass data via state.extra and crash on direct deep-link entry (no deep-link guard).
  • ChangePasswordScreen does not require the user's current password to match — backend handles that validation; the screen surfaces server errors as generic messages.
  • Both flows treat any non-2xx as 'Something went wrong. Try again!' to avoid enumeration / leaking server state.
lib/src/features/shared/auth/forget_password/presentation/forget_password.dart
lib/src/features/shared/auth/forget_password/presentation/reset_password_otp_screen.dart
lib/src/features/shared/auth/forget_password/presentation/reset_password_screen.dart
lib/src/features/shared/auth/change_password/presentation/change_password.dart

Generic error messages are a deliberate design choice — see the matching comment in change_password_notifier.dart.

Domain layer

Login Notifier · presentation/providers · Notifier<LoginState>

Riverpod Notifier orchestrating email/password, Google and Apple sign-in. On password login it issues a 'dual login' (customer + seller endpoints) to discover whether the same credentials also resolve a seller account, then persists both tokens and forces the user role accordingly.

  1. Screen calls login(email, password); state flips to isLoading + isPasswordLoading.
  2. AuthApiService.dualLogin posts /auth/customer/emailpass-verified, then /auth/seller/emailpass-verified.
  3. Seller 401 is swallowed and returned as null — the user is treated as customer-only.
  4. _persistAuthenticatedSessions saves customer + (optional) seller tokens via AuthStorageService.
  5. userRoleProvider.notifier.setRole(freelancer|client) forces the active role based on which tokens exist.
  6. State updates to isAuthenticated = true; the GoRouter redirect then bounces /login → /home.
  • Seller endpoint returns 401 → treat as 'not a seller', proceed as customer-only (loginSeller swallows the 401).
  • Google/Apple cancellation throws GoogleSignInExceptionCode.canceled / AuthorizationErrorCode.canceled — clear loading flags but do not surface as an error.
  • Apple only sends name/email on the first authorization — subsequent logins must rely on the backend mapping by Apple subject ID.
  • skipTermsConsent flag short-circuits the (currently commented-out) ToS pre-check; the router still enforces ToS post-login.
  • Token may be expired when restored from storage — JwtHelper.isTokenExpired is logged but the UI does not yet trigger a refresh.
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:16 // LoginNotifier class
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:47 // login(email,password)
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:154 // loginWithGoogle
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:271 // loginWithApple
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:370 // _persistAuthenticatedSessions
lib/src/features/shared/auth/login/presentation/providers/auth/login_state.dart

The commented-out termsConsentService.getConsentRequirement block is the previous design — ToS is now enforced exclusively by the GoRouter redirect (see auth-routes-guard). Calls are delegated to AuthApiService — see the auth-api-service node for the wire-level inventory.

Register Notifier · auth/register · Notifier<RegisterState>

Posts a new customer to /auth/customer/emailpass-verified/register but deliberately does NOT persist a session — the user is logged in only after they verify the OTP emailed by the backend (see the OTP step in the onboarding-register wizard).

  1. RegisterStepPage submits → register(email, password).
  2. AuthApiService.register POSTs the credentials and gets back a RegisterResponse.
  3. On success state.isVerified flips to true (semantic: 'request accepted, awaiting OTP').
  4. Wizard advances to OtpStepPage; the actual session is saved by AuthStorageService.saveSessionOtp after /auth/customer/emailpass-verified/verify.
  • Backend returns 200 with {type: 'unauthorized'} — surfaced as an Exception (Medusa-style soft failure).
  • Email already exists → backend returns a structured error; message is hoisted unchanged into state.error.
  • Session is intentionally not saved here — calling save before OTP verification would log the user into an unverified account.
lib/src/features/shared/auth/register/register_notifier.dart:17 // register()
lib/src/features/shared/auth/register/register_request.dart
lib/src/features/shared/auth/register/register_response.dart
lib/src/core/services/auth_service.dart:193 // AuthApiService.register

isVerified in RegisterState is misleadingly named — it means 'register call succeeded', not 'email verified'. The actual verification happens in the OTP step.

Forgot Password Notifier · forget_password/presentation/providers

Three coordinated notifiers that drive the email-based reset flow: ForgotPasswordNotifier triggers the reset email, ResetPasswordOtpNotifier verifies the 6-digit code, UpdatePasswordNotifier sets the new password. Each owns its own state and is consumed by a dedicated screen.

  1. ForgetPasswordScreen → sendResetEmail(email) POSTs /auth/customer/emailpass-verified/reset-password.
  2. 201 flips emailSent=true; user is navigated to ResetPasswordOtpScreen with the email in extra.
  3. ResetPasswordOtpScreen verifies the code, receives a short-lived reset token.
  4. ResetPasswordScreen submits new password + token; backend rotates credentials.
  5. User is bounced to LoginScreen with the new password.
  • Backend returns the same 201 whether the email exists or not (enumeration-safe) — UI must not leak existence.
  • Reset token expires server-side; UI surfaces a generic 'Something went wrong' to avoid hinting at expiry.
  • If the user navigates back from the OTP screen, the email field must repopulate — state is held by Riverpod, not by route extra.
lib/src/features/shared/auth/forget_password/presentation/providers/forgot_password_notifier.dart:7
lib/src/features/shared/auth/forget_password/presentation/providers/reset_password_otp_notifier.dart
lib/src/features/shared/auth/forget_password/presentation/providers/update_password_notifier.dart

The seller role has no separate forgot-password flow — backend resets the customer credential and the seller token is invalidated implicitly on next dual-login.

Change Password Notifier · change_password/presentation/providers

Authenticated password rotation from the settings screen. Posts old + new password to /store/change-password/emailpass-verified using the current customer token.

  1. ChangePasswordScreen calls changePassword(oldPassword, newPassword).
  2. DioHelper.post fires with authScope='customer' — interceptor attaches the customer bearer token.
  3. 200 → state.isUpdated = true; the screen pops back to settings with a success snackbar.
  4. Non-200 / network error → state.error is set with a generic message.
  • Backend HTML 500 (rare) — caught and surfaced as 'Server error. Please try again later.' (see auth-api-service _handleBadResponse-like logic inline).
  • Wrong old password → 401/403 with message rewritten to 'Current password is incorrect'.
  • Successful rotation does not invalidate the local token — the same bearer keeps working until natural expiry.
lib/src/features/shared/auth/change_password/presentation/providers/change_password_notifier.dart:13
lib/src/core/services/auth_service.dart:270 // AuthApiService.changePassword (legacy /auth/password-change/)

There are TWO change-password endpoints in the code: this notifier hits /store/change-password/emailpass-verified; AuthApiService.changePassword hits /auth/password-change/. Only the notifier path is wired into the UI today.

Auth State Notifier · core/services/auth_storage.dart · StateNotifier<AuthState>

Global StateNotifier exposing the (isLoggedIn, role) tuple. On construction it inspects persisted tokens and resolves the initial role: seller token present → freelancer, customer-only → client, otherwise guest.

  1. App boot → AuthStateNotifier ctor calls _loadInitialState.
  2. _loadInitialState reads isLoggedIn from SharedPreferences and inspects hasSellerRole().
  3. switchRole(newRole) is invoked when the user toggles between client and freelancer in the UI — only succeeds if a token for that role exists.
  4. logout() clears the session, deletes the FCM token, and resets state to AuthState(false, guest).
  • AuthState overrides == and hashCode so refreshAuthState() does not re-trigger watchers when the (isLoggedIn, role) tuple is unchanged — without this, /store/customers/me would fetch twice on home boot (see explicit comment in code).
  • logout swallows NotificationService.deleteToken errors when FCM is not initialized.
  • switchRole silently no-ops if the requested role has no stored token — the UI must not assume the call succeeded.
lib/src/core/services/auth_storage.dart:257 // authStateProvider
lib/src/core/services/auth_storage.dart:263 // AuthStateNotifier
lib/src/core/services/auth_storage.dart:328 // AuthState equality override

Lives in auth_storage.dart alongside the storage service rather than under features/auth — it is consumed across the whole app (settings, profile, navigation) so its DI scope is core.

Data layer

Auth API Service · core/services/auth_service.dart

Thin Dio wrapper for every auth endpoint. Owns the dual-login dance: loginCustomer is mandatory, loginSeller is best-effort (returns null on 401 instead of throwing) so callers can detect 'user is customer-only' without an exception path.

  1. dualLogin(request) calls loginCustomer first — any non-401 customer error throws.
  2. Then calls loginSeller — a 401 is swallowed and returned as null.
  3. Returns DualLoginResponse{customerToken, sellerToken?} with hasBothRoles / isCustomerOnly helpers.
  4. register, verify (OTP), loginGoogle, loginApple, changePassword are independent endpoints — no dual handling.
  • Every call annotates Options with a RequestTrace (feature/screen/action/authScope) — used by TraceInterceptor in debug mode.
  • Apple/Google login both return a DualLoginResponse with sellerToken=null; OAuth users have no seller path yet.
  • changePassword has bespoke error mapping for HTML 4xx/5xx responses (DOCTYPE detection) because the Medusa admin path leaks HTML in some failure modes.
lib/src/core/services/auth_service.dart:14 // DualLoginResponse
lib/src/core/services/auth_service.dart:24 // AuthApiService
lib/src/core/services/auth_service.dart:99 // dualLogin
lib/src/core/services/auth_service.dart:127 // loginGoogle
lib/src/core/services/auth_service.dart:160 // loginApple

There is a legacy AuthService at lib/src/features/shared/auth/login/data/auth_service.dart hitting /auth/sign-in/email — only used by the older AuthRepositoryImpl, not by the current LoginNotifier. Endpoints already exposed via the notifier nodes (login-notifier, register-notifier, change-password-notifier) are NOT duplicated here — only the legacy /auth/sign-in/email is.

Auth Storage Service · core/services/auth_storage.dart · SharedPreferences

Wraps SharedPreferences for everything session-shaped: customer + seller tokens, isLoggedIn flag, profileSetupComplete, ToS acceptance, pending seller registration, and per-(fcm,auth) tokens already synced to backend.

  1. saveCustomerSession / saveSellerSession write token + isLoggedIn=true.
  2. getCustomerToken / getSellerToken are used by the Dio auth interceptor on every request.
  3. savePendingRegistrationData stashes phone/username/firstName/lastName between register and OTP verify.
  4. savePendingSellerOnboarding holds seller-type + selected services across the 'devenir prestataire' wizard.
  5. clearSession on logout removes tokens, role, profile-setup flag, ToS flag, and pending onboarding selections.
  • FCM tracking key is keyed by (fcmToken, last 20 chars of authToken) so a token rotation forces a re-sync (see _getFcmTrackingKey).
  • clearSellerSession is callable independently — used when a user 'unbecomes' a seller without logging out.
  • Pending seller onboarding keys persist across app restarts; clearPendingSellerOnboarding must be called on completion to avoid stale carry-over.
lib/src/core/services/auth_storage.dart:9 // AuthStorageService
lib/src/core/services/auth_storage.dart:18 // saveCustomerSession
lib/src/core/services/auth_storage.dart:219 // _getFcmTrackingKey
lib/src/core/services/auth_storage.dart:242 // clearSession

No secure storage — tokens live in SharedPreferences. Acceptable for Medusa session tokens (server-revocable) but means a rooted device can exfiltrate them. Local storage keys (no HTTP, SharedPreferences): • 'customer_token' — write: saveCustomerSession / saveSessionOtp — read: getCustomerToken (consumed by DioHelper auth interceptor) — clear: clearSession • 'seller_token' — write: saveSellerSession — read: getSellerToken — clear: clearSession + clearSellerSession • 'is_logged_in' — bool — read: isLoggedIn (consumed by GoRouter redirect + HomeScreen) • 'is_profile_setup_complete' — bool — write: markProfileSetupComplete (post-onboarding) • 'has_accepted_tos' — bool — write: markTosAccepted (after /store/tos/consent succeeds) • 'is_pending_seller_reg' — bool — write: setPendingSellerRegistration — gates the auth redirect to /onboarding_register_seller • 'pending_reg_{phone|username|first_name|last_name}' — String — write: savePendingRegistrationData (between register POST and OTP verify) — consumed by lib/src/features/shared/onboarding_register/presentation/pages/otp_step_page.dart • 'pending_seller_{type|services}' — write: savePendingSellerOnboarding — consumed by lib/src/features/client/devenir_prestataire/devenir_prestataire_screen.dart • 'fcm_sent_<fcmToken>_<authSuffix>' — bool — write/read: markFcmTokenSent / isFcmTokenSent — dedupes /fcm/token uploads

Dio Auth Interceptor · core/network/dio_helper.dart · _authInterceptor

Per-request resolution of which token to attach. Five-step priority: explicit authScope='none' opt-out → caller-supplied Authorization header → explicit scope='seller'|'customer' → path heuristics (/vendor /seller → seller; /store /social /auth/customer → customer) → fall back to persisted active role.

  1. onRequest reads options.extra['auth_scope'] from the per-call RequestTrace.
  2. If 'none' → strip Authorization and pass through.
  3. If caller already set Authorization → respect it but also attach seller_tok header for /vendor paths.
  4. Otherwise pick scope by extra → path prefix → loadUserRole() (persisted role).
  5. Attach Bearer token from AuthStorageService; fire-and-forget _checkAndSendFcmToken on first use of that token.
  • /vendor paths always also receive a seller_tok header in addition to Authorization — required by the backend's vendor-impersonation middleware.
  • FCM sync uses a separate raw Dio instance to avoid interceptor recursion (would otherwise self-call /fcm/token).
  • If no token is found for the resolved scope, the request is sent unauthenticated (no early error) — debug log only.
  • _fcmSyncInProgress set prevents concurrent FCM sync for the same auth token during burst requests at app start.
lib/src/core/network/dio_helper.dart:143 // _authInterceptor
lib/src/core/network/dio_helper.dart:129 // _isSellerOnlyPath
lib/src/core/network/dio_helper.dart:82 // _checkAndSendFcmToken
lib/src/core/network/request_trace.dart // RequestTrace.toOptions

The path heuristics are the load-bearing piece — many endpoints don't pass authScope explicitly and rely on this fallback. Adding a new endpoint family means updating _isSellerOnlyPath / _isCustomerOnlyPath.

JWT Helper · core/jwtHelper.dart

Pure utility for decoding the JWT payload (no signature verification, since the client only consumes the token) and computing expiry. Used by LoginNotifier on session restore to log whether the cached token is already past its 5-minute pre-expiry window.

  1. decodeToken splits the JWT into 3 parts, base64-url decodes the middle segment.
  2. isTokenExpired compares exp * 1000ms against now() minus a 5-minute safety margin.
  3. getTokenExpiry returns the raw DateTime for diagnostic use.
  • Malformed JWT (not 3 parts) returns null from decodeToken and is treated as expired by isTokenExpired.
  • Missing exp claim is also treated as expired — the app prefers to force a re-login over trusting a non-expiring token.
  • There is no refresh logic yet — isTokenExpired is logged but not acted upon (LoginNotifier._checkAuthStatus is a TODO).
lib/src/core/jwtHelper.dart:3 // JwtHelper
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:36 // _checkAuthStatus uses isTokenExpired

Despite the camelCase filename (jwtHelper.dart), the class is JwtHelper. Don't rename without updating every import.

Dependency Injection layer

GoRouter Auth Redirect · core/routing/app_router.dart · redirect()

The single redirect function in goRouter performs the entire auth funnel: not-logged-in → /login; logged-in but profileSetupComplete=false → /onboarding_register(_seller); ToS not accepted → /terms; seller-only routes hidden when !hasSellerRole. It is the de-facto auth state machine.

  1. On every navigation, fetches isLoggedIn, isProfileSetupComplete, hasSellerRole, isPendingSellerRegistration, ToS status.
  2. Splash route is always allowed through (initial loading screen).
  3. Unauthenticated + auth route → pass; unauthenticated + anything else → /login.
  4. Authenticated + incomplete profile → /onboarding_register or /onboarding_register_seller (based on pending flag).
  5. Authenticated + complete + ToS pending → /terms; if already there → pass.
  6. Authenticated + on auth/onboarding route → bounce to /home.
  7. Authenticated + seller-only path + !hasSellerRole → /home.
  • _hasConsentedToS is a module-level memoization to stop the redirect spamming /tos/latest on every frame while the user is parked on the ToS screen — without it, the router enters a backend-pinging loop.
  • isPendingSellerRegistration picks which onboarding wizard to route to, branching what is otherwise a uniform onboarding flow.
  • isEditPrestationRoute uses a regex on /prestation/:id/edit because GoRouter does not expose the route name during the redirect phase.
lib/src/core/routing/app_router.dart:76 // _checkIsLoggedIn
lib/src/core/routing/app_router.dart:94 // _verifyTosConsent (memoized)
lib/src/core/routing/app_router.dart:131 // GoRouter.redirect
lib/src/core/routing/routes.dart // AppRoute enum

Every redirect awaits SharedPreferences reads serially — adding more checks here will slow first-frame navigation. The pattern is to push new gates into _verifyTosConsent-style memoized helpers.

Auth Provider Wiring · Riverpod NotifierProviders for the auth feature

There is no GetIt — every auth dependency is a Riverpod Provider declared next to its notifier. authApiServiceProvider and authStorageServiceProvider are the two leaf providers; the notifiers compose them in their build() methods.

  1. authApiServiceProvider exposes a singleton AuthApiService (wraps Dio).
  2. authStorageServiceProvider exposes AuthStorageService (wraps SharedPreferences).
  3. loginNotifierProvider, registerNotifierProvider, forgotPasswordProvider, changePasswordProvider — each ref.read the two leaf providers in build().
  4. authStateProvider (StateNotifierProvider, legacy API) exposes the global AuthState consumed app-wide.
  5. userRoleProvider (defined in user_role.dart) is the third global the auth feature mutates — switchRole / setRole / saveUserRole.
  • authStateProvider uses the legacy StateNotifierProvider (flutter_riverpod/legacy.dart) while all per-feature notifiers use the modern NotifierProvider — both coexist on Riverpod 3.x.
  • build() is called once per notifier lifecycle; ref.read inside build is safe but ref.watch would create a rebuild loop.
  • Apple/Google SDKs are constructed lazily inside the notifier methods (not in DI) so dotenv must be loaded first — main.dart guarantees ordering.
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:411 // authApiServiceProvider
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:415 // authStorageServiceProvider
lib/src/features/shared/auth/login/presentation/providers/auth/login_provider.dart:419 // loginNotifierProvider
lib/src/core/services/auth_storage.dart:257 // authStateProvider
lib/src/core/services/user_role.dart

The two leaf providers are duplicated as plain class instantiations elsewhere (e.g. NotificationService also constructs AuthStorageService directly) — Riverpod is the canonical wiring but not strictly enforced.

Home & App Shell

The post-login app shell: a ShellRoute that wraps every secondary screen with a persistent bottom nav, an IndexedStack of role-aware tabs, a 4×4 home grid that branches between client and freelancer landing pages, and a radial 'arc' create-menu.

Riverpod · GoRouter ShellRoute · PageView

API surface

MethodEndpointPurposeAuthStatus
GET/store/tosFetch the latest published ToS document (id, version, content URL).no status
GET/store/tos/consent?termsVersion=:vCheck whether the current user has consented to a given ToS version.no status
POST/store/tos/consentRecord the user's consent to a ToS version.no status

Presentation layer

Home Screen · presentation/home.dart · ConsumerStatefulWidget

Top-level Home screen. Holds the IndexedStack of tab pages and decides which set of tabs to render based on isLoggedIn. AutomaticKeepAliveClientMixin keeps every tab subtree warm so swiping back doesn't rebuild map / messages / profile.

  1. initState schedules a post-frame callback: refreshAuthState + _checkInitialTos + _checkPendingSeller.
  2. build watches authStateProvider and bottomNavIndexProvider.
  3. _buildPages returns [Home, Map, Profile] for guests or [Home, Map, Messages, Profile] for authenticated users.
  4. Horizontal swipe left pushes /search via context.pushNamed.
  5. ToS check on first frame redirects to /terms if the consented version is stale.
  • Pending-seller redirect: if isPendingSellerRegistration is true on entry, the user is sent to /devenir-prestataire even though they completed initial onboarding.
  • ToS check is duplicated here (in addition to the GoRouter redirect) because the redirect's memoization makes it idempotent — the home-screen call is the authoritative re-check after login.
  • currentIndex is clamp(0, pages.length - 1) to survive a guest→logged-in transition where the tab count grows mid-session.
lib/src/features/shared/home/presentation/home.dart:15 // HomeScreen
lib/src/features/shared/home/presentation/home.dart:31 // initState hooks
lib/src/features/shared/home/presentation/home.dart:77 // _buildPages

Horizontal-drag → /search is intentional but undiscoverable; the visible CTA is the tiny circular icon in HomePage. Don't 'fix' the size without coordinating with design.

Home Page (tab content) · presentation/home_page.dart

The 'Home' tab itself — a 2-page PageView showing FirstPage (primary 16-item grid) and SecondPage (secondary grid). Renders the Stories strip above the grid when isLoggedIn, and a compact search CTA below.

  1. PageController drives a PageView with [FirstPage, SecondPage].
  2. onPageChanged updates homeCurrentPageIndicatorProvider so the dot indicator follows.
  3. Stories widget appears only for authenticated users.
  4. Tapping the search CTA pushes /search via context.push.
  • PageView keeps both pages alive via AutomaticKeepAliveClientMixin on HomePage — switching pages does not refetch stories.
  • Indicator color flips on Theme.brightness — required because the white primary brand color is invisible on the light bg.
  • When commented-out search CTA returns, the tiny icon-only fallback must keep working for users who haven't pulled the new build.
lib/src/features/shared/home/presentation/home_page.dart:17 // HomePage
lib/src/features/shared/home/presentation/home_page.dart:49 // PageView.builder
lib/src/features/shared/home/presentation/home_page.dart:73 // search CTA

Large commented-out block at the bottom is the previous full-bar search CTA — kept as design reference, not dead code by accident.

Home Grid (FirstPage / SecondPage) · presentation/widgets/{first,second}_page.dart

Two 4×4 grids of HomeGridItem tiles. FirstPage._prestatireItems and _clientItems are two parallel static lists picked at runtime based on userRoleProvider. Each tile carries a routePath, image asset, requiresLogin flag and optional rolesAllowed filter.

  1. FirstPage.build reads userRoleProvider and picks the matching item list.
  2. Each HomeGridItem renders the asset (light/dark variant) + label + 'NEW' badge.
  3. Tapping a tile checks requiresLogin against the current auth state, then context.push(routePath).
  4. Items with routePath like '/urgent' / '/concours' point at not-yet-implemented routes — they render but navigate to a 404-style stub.
  • imageDark fallback to image when the dark variant is absent — handled by HomeGridItem.
  • isNewFeature controls a small 'NEW' chip; never auto-cleared, must be manually set false on each new build.
  • Several routes (/urgent, /domicile, /event, /before-after, /formations, /finances, /concours) are placeholders — the team uses them as visible TODOs.
lib/src/features/shared/home/presentation/widgets/first_page.dart:13 // _prestatireItems
lib/src/features/shared/home/presentation/widgets/first_page.dart:37 // _clientItems
lib/src/features/shared/home/presentation/widgets/second_page.dart
lib/src/features/shared/home/presentation/widgets/home_grid_item.dart

Item lists are static const-ish but use HomeGridItemData (not const due to AppRoute getters); refactoring AppRoute.path to a const string would let the whole list be const.

App Shell Scaffold · presentation/widgets/app_shell_scaffold.dart

ConsumerStatefulWidget wrapping every screen reachable via the ShellRoute with a 5-item BottomNavigationBar (or 3 items for guests). Index 2 is the floating '+' that toggles the arc-menu instead of navigating.

  1. Watches authStateProvider, arcMenuProvider and bottomNavIndexProvider.
  2. _buildNavItems returns 3 or 5 items depending on isLoggedIn.
  3. _onItemTapped: if logged in and index==2 → toggle arcMenuProvider; else update bottomNavIndexProvider and context.goNamed('home').
  4. When arcMenuProvider is true, draws a BackdropFilter blur layer and the ArcSearchMenu on top.
  • navIndex is shifted around the '+' slot for authenticated users (currentIndex < 2 ? currentIndex : currentIndex + 1) so the underlying page list never includes the '+'.
  • context.goNamed('home') on tab-tap rather than IndexedStack.setState — required because the user could be on a secondary screen pushed on top of the shell.
  • Backdrop blur uses ImageFilter.blur(8,8) + 25% black overlay — tuning this affects perceived perf on low-end Android.
lib/src/features/shared/home/presentation/widgets/app_shell_scaffold.dart:17 // AppShellScaffold
lib/src/features/shared/home/presentation/widgets/app_shell_scaffold.dart:145 // _buildNavItems
lib/src/features/shared/home/presentation/widgets/app_shell_scaffold.dart:197 // _onItemTapped

Commented-out 'Mode Prestataire' banner is intentional — toggled off post-design review. Don't delete without checking with product first.

Arc Search Menu · presentation/widgets/curved_icon_menu.dart

Radial 'fan out' menu of quick-create shortcuts that appears when the floating '+' is tapped. Animates with a single AnimationController using easeOutBack, placing items on a 120-radius arc above the nav bar.

  1. AnimationController(.forward) on initState; animation is consumed by each item's Transform.
  2. Each _ArcMenuItemData has imagePath, optional imagePathDark, label and an onTap callback.
  3. Tapping an item closes the menu (arcMenuProvider.notifier.state = false) and pushes its route.
  4. Tapping outside (the BackdropFilter) closes the menu via the same provider.
  • Animation is one-way: there's no reverse on dismiss, the whole subtree just unmounts.
  • Items render even before the controller completes — a fast '+' tap then dismiss looks janky without the BackdropFilter masking it.
lib/src/features/shared/home/presentation/widgets/curved_icon_menu.dart:25 // ArcSearchMenu
lib/src/features/shared/home/presentation/widgets/curved_icon_menu.dart:37 // _radius constant

ItemSize 56 + radius 120 is balanced for a phone of ~390 logical width — tablets will leave dead space at the edges.

Domain layer

HomeGridItemData · models/home_grid_item_model.dart

Lightweight model describing each tile in the home grid: label, routePath, image, optional dark variant, requiresLogin gate, optional rolesAllowed filter and an isNewFeature badge flag.

  1. Constructed inline in FirstPage._prestatireItems and _clientItems.
  2. isAllowedFor(role) returns true when rolesAllowed is null or contains the given role.
  3. HomeGridItem widget consumes the data to render image + label and to gate the tap handler.
  • rolesAllowed=null is interpreted as 'any role' — not a 'no roles' sentinel.
  • requiresLogin is checked client-side only; deep-linking to the route directly relies on the GoRouter redirect to bounce guests to /login.
lib/src/features/shared/home/models/home_grid_item_model.dart:3 // HomeGridItemData

There's no equality override — the items lists are recreated on every build but they're const-ish enough that it doesn't cause widget churn.

Home State Providers · provider/provider.dart · StateProvider × 3

Three small StateProvider<int|bool> values backing the shell UI: bottomNavIndexProvider (active tab), homeCurrentPageIndicatorProvider (PageView dot), arcMenuProvider (radial menu open).

  1. AppShellScaffold reads bottomNavIndexProvider; nav tap writes back.
  2. HomePage onPageChanged writes homeCurrentPageIndicatorProvider.
  3. AppShellScaffold toggles arcMenuProvider on the '+' tap and on backdrop tap.
  • Providers reset to 0/false when the ProviderScope is torn down (only at app kill) — they survive logout.
  • bottomNavIndexProvider is indexed into a list whose length depends on auth state — callers must clamp.
lib/src/features/shared/home/provider/provider.dart:5 // bottomNavIndexProvider
lib/src/features/shared/home/provider/provider.dart:7 // homeCurrentPageIndicatorProvider
lib/src/features/shared/home/provider/provider.dart:9 // arcMenuProvider

These are legacy StateProviders (flutter_riverpod/legacy.dart) — fine for trivial values, but new state goes through NotifierProvider.

Data layer

isLoggedIn Provider · provider/provider.dart · FutureProvider<bool>

Convenience FutureProvider that reads isLoggedIn from AuthStorageService once. Most of the shell uses authStateProvider directly; this provider is here for places that want a Future<bool> rather than a live AuthState.

  1. authStorageProvider exposes AuthStorageService() (re-instantiated per provider build).
  2. isLoggedInProvider reads it and forwards AuthStorageService.isLoggedIn().
  3. Future resolves once; the provider caches the result until ref.invalidate.
  • Stale after logout — code that consumes this must invalidate or prefer authStateProvider for live updates.
  • Returns false for the (logged in but tokens cleared) edge case because isLoggedIn flag is what's checked, not the tokens themselves.
lib/src/features/shared/home/provider/provider.dart:11 // authStorageProvider
lib/src/features/shared/home/provider/provider.dart:15 // isLoggedInProvider
lib/src/core/services/auth_storage.dart:81 // isLoggedIn

Duplicates a slice of AuthStateNotifier's responsibility — keep only one source of truth in new code.

Dependency Injection layer

ShellRoute + Home Route · core/routing/app_router.dart · ShellRoute

Wires the home route and almost every authenticated screen inside a single ShellRoute whose builder is AppShellScaffold. This is what makes the bottom nav persistent across pushes — secondary screens are children of the shell, not pushes on top of the home screen.

  1. GoRouter is constructed in app_router.dart with the redirect we describe in the auth feature.
  2. Routes are split: auth/onboarding/splash live OUTSIDE the ShellRoute (no nav bar).
  3. Home, settings, cart, prestataires, profile, orders, seller dashboards, etc. live INSIDE the ShellRoute and inherit AppShellScaffold.
  4. Some routes (e.g. /map, /messages) are redirect-only GoRoutes that bounce back to /home so the IndexedStack can switch tabs.
  • Pushing onto a ShellRoute child preserves the nav bar; pushing the same route name from outside the shell breaks IndexedStack swap.
  • The /add-prestation route lives outside the ShellRoute on purpose — it's a full-screen wizard that should NOT show the nav bar.
  • /map and /messages exist as redirect-only routes purely to support deep-linking to a specific bottom-nav tab without a custom URL scheme.
lib/src/core/routing/app_router.dart:332 // ShellRoute builder
lib/src/core/routing/app_router.dart:338 // home GoRoute
lib/src/core/routing/app_router.dart:344 // /map redirect
lib/src/core/routing/app_router.dart:350 // /messages redirect
lib/src/core/routing/app_router.dart:731 // /add-prestation outside ShellRoute

Adding a new top-level screen? Decide first whether it needs the nav bar — placement (inside vs outside ShellRoute) is the deciding factor.

Home ToS / Pending-Seller Bootstrap · presentation/home.dart · _checkInitialTos / _checkPendingSeller

Post-frame side-effects fired the first time HomeScreen mounts: re-check ToS consent (in case the version bumped while logged in), and re-route to /devenir-prestataire if a pending-seller flag is set.

  1. WidgetsBinding.instance.addPostFrameCallback schedules the work after the first build.
  2. authStateProvider.notifier.refreshAuthState() reloads (isLoggedIn, role) from storage.
  3. _checkInitialTos calls TosApiService.getLatestTos + checkConsent; if not consented, context.goNamed(termsOfService).
  4. _checkPendingSeller reads AuthStorageService.isPendingSellerRegistration; if true, context.goNamed(devenirPrestataire).
  • Both checks early-return when isLoggedIn is false — guests don't touch /tos/latest at all.
  • If both ToS-stale and pending-seller fire, ToS wins (it's checked first and context.goNamed is async-fire-and-forget).
  • Errors are swallowed with a debugPrint — backend outage during ToS check leaves the user on /home rather than blocking them.
lib/src/features/shared/home/presentation/home.dart:34 // post-frame callback
lib/src/features/shared/home/presentation/home.dart:41 // _checkInitialTos
lib/src/features/shared/home/presentation/home.dart:63 // _checkPendingSeller
lib/src/features/shared/tos/data/tos_api_service.dart

Treat this as a redundant safety net — the GoRouter redirect is the primary gate. If you remove either check here, the redirect's memoization (_hasConsentedToS) must be invalidated on login.

Cart

Two-stage cart: an offline-first local cart kept per-user in SharedPreferences during browsing, then a server-side Medusa cart created lazily at checkout. The Medusa cart owns line items, promo codes, shipping and payment sessions.

Riverpod · Medusa Store API · SharedPreferences

API surface

MethodEndpointPurposeAuthStatus
POST/store/cartsCreate a fresh Medusa cart of a given type (provision or physical).no status
GET/store/carts/:cartIdFetch the current server-side cart (lines, totals, addresses, promotions).no status
POST/store/carts/:cartId/line-itemsAdd a line item to the cart.no status
POST/store/carts/:cartId/line-items/:lineItemIdUpdate or delete (quantity=0) a line item.no status
POST/store/carts/:cartIdPatch arbitrary cart fields (shipping address, email, region…).no status
GET/store/shipping-options?cart_id=:idList shipping options available for the cart's destination + items.no status
POST/store/carts/:cartId/shipping-methodsAttach a chosen shipping method to the cart.no status
POST/store/carts/:cartId/promotionsApply or clear promo codes on the cart.no status
GET/store/payment-providers?region_id=:idList payment providers configured for the cart's region.no status
POST/store/payment-collectionsCreate a payment collection for the cart.no status
POST/store/payment-collections/:idResync the amount on an existing payment collection (e.g. after cart change).no status
POST/store/payment-collections/:id/payment-sessionsInitialize a payment session with a chosen provider; returns the Stripe client_secret.no status
POST/store/payment-collections/:id/partial/payment-sessionMulti-session partial payment for booking deposits (e.g. deposit + remainder).no status
POST/store/carts/:cartId/set-orderComplete the cart and convert it into an order.no status
GET/store/carts/:cartId/line-items/:lineItemId/questionsFetch booking-questions for a provision line item.no status
POST/store/carts/:cartId/line-items/:lineItemId/questions/:questionIdPersist a customer's answer to a booking question (option / comment / number / image).no status
DELETE/store/carts/:cartId/line-items/:lineItemId/questions/:questionIdClear an answer to a booking question.no status

Presentation layer

Cart Screen · views/cart_screen.dart · ConsumerStatefulWidget

Renders the local cart: list of LocalCartItem rows with qty steppers, a per-row checkbox driven by CartSelectionController, a running total of selected items, and a 'Checkout' button that hands off to CheckoutScreen.

  1. Watches cartControllerProvider and cartSelectionProvider for state.
  2. Empty state renders a centered illustration with a 'Continue shopping' CTA.
  3. Qty stepper calls CartController.updateQuantity; tap-to-remove calls removeItem.
  4. Promo code input is local-only here — the actual /store/carts/:id/promotions call happens after the backend cart is created in checkout.
  5. Tapping Checkout pushes the CheckoutScreen, passing only the selected variant IDs (not the full local list).
  • PaymentModesData inline list (cash/Paytm/online) is a UI placeholder — the real payment provider list is fetched in CheckoutScreen via fetchPaymentProviders.
  • Screen implements TopBarClickListener for the shared top-bar callbacks — back, search, notification icons.
  • Hot-reload while the cart is empty re-runs fillData and resets paymentModeList — safe because the list is not user-mutated.
lib/src/features/shared/cart/views/cart_screen.dart:18 // CartScreen
lib/src/features/shared/cart/views/cart_screen.dart:60 // ref.watch(cartControllerProvider)

The S.current.cashOnDeliveryLabel-style placeholder rows in fillData are remnants from a previous design where this screen also picked payment methods — keep until the new checkout is fully launched.

Domain layer

LocalCartItem · models/local_cart_item.dart

Per-device cart line. Keyed by variantId, carries productId, title, optional thumbnail, variantTitle, mutable quantity and unitPrice. Round-trips through JSON via encodeList / decodeList for storage.

  1. addToCart constructs a LocalCartItem and either pushes it onto the list or increments an existing entry's quantity.
  2. copyWith is used by the controller to immutably bump quantity without losing other fields.
  3. encodeList serializes the whole list to a JSON string before SharedPreferences write.
  • decodeList catches any decoding error and returns []; corrupt local data is silently reset rather than crashing.
  • Quantity is marked non-final but the controller always uses copyWith — direct mutation is a footgun that bypasses _saveToStorage.
  • totalPrice is computed (no rounding) — float drift accumulates on big quantities but is reconciled when the backend cart is built.
lib/src/features/shared/cart/models/local_cart_item.dart:5 // LocalCartItem
lib/src/features/shared/cart/models/local_cart_item.dart:24 // totalPrice
lib/src/features/shared/cart/models/local_cart_item.dart:69 // encodeList/decodeList

No backend ID field — once the user proceeds to checkout, items are 'translated' into backend line-items by variantId. Local IDs are not preserved server-side.

Cart Controller · controllers/cart_controller.dart · Notifier<List<LocalCartItem>>

Notifier owning the local cart state. Watches currentCustomerIdProvider; when the customer ID changes (login, account switch) it reloads from per-user SharedPreferences. No network calls live here — the backend cart is created later at checkout.

  1. build() ref.watch(currentCustomerIdProvider) and on a non-null id different from _userId, triggers _loadFromStorage.
  2. _loadFromStorage reads via LocalStorageService.getLocalCartItems(userId).
  3. addToCart / updateQuantity / removeItem mutate state, then _saveToStorage persists.
  4. updateQuantity routes qty<=0 to removeItem.
  5. clearCart wipes state and clears the per-user key via LocalStorageService.clearLocalCartItems.
  • If _userId is null when _saveToStorage runs, it logs and returns without writing — silent no-op when the user isn't loaded yet.
  • User switches account in-session → currentCustomerIdProvider changes → previous cart stays in memory until _loadFromStorage replaces it (brief stale window).
  • Guest mode has no customerId, so the cart is purely in-memory and is lost on app kill.
lib/src/features/shared/cart/controllers/cart_controller.dart:9 // CartController
lib/src/features/shared/cart/controllers/cart_controller.dart:15 // ref.watch(currentCustomerIdProvider)
lib/src/features/shared/cart/controllers/cart_controller.dart:42 // addToCart
lib/src/features/shared/cart/controllers/cart_controller.dart:130 // cartControllerProvider

The explicit watch+_userId guard exists because Riverpod will rebuild whenever the customerId AsyncValue ticks even if the id is unchanged — without the diff check, every rebuild would re-read storage.

Cart Selection Controller · controllers/cart_selection_controller.dart

Tracks which variantIds the user has ticked for checkout. Derived from cartControllerProvider so it auto-resets to 'all selected' whenever the cart list itself changes.

  1. build() watches cartControllerProvider and returns cartItems.map(variantId).toSet().
  2. toggleItem flips one entry; selectAll / deselectAll bulk update.
  3. CartScreen reads this set to decide which items show a tick + which contribute to the running total.
  • Adding a new item to the cart auto-selects it (build re-runs and the new variantId enters the set).
  • Removing an item triggers build → the absent variantId silently drops out of the selection.
  • Selection is in-memory only; it is not persisted across app kills.
lib/src/features/shared/cart/controllers/cart_selection_controller.dart:5 // CartSelectionController
lib/src/features/shared/cart/controllers/cart_selection_controller.dart:9 // auto-select on cart change

By rebuilding from the cart, selection 'recovers' from edits without the screen having to call selectAll manually — but a screen that 'unticked everything' loses that state on the next cart edit.

Backend Cart Model · models/cart.dart + cart_item.dart

Mirrors the Medusa Cart entity returned by /store/carts/{id}: id, region, currency, line items, totals (total/subtotal/itemTotal/shipping/discount/tax), email, shipping & billing addresses.

  1. Cart.fromJson maps the Medusa payload into a typed Cart.
  2. CartItem.fromJson maps a single line-item including thumbnail + variant info.
  3. CheckoutScreen consumes the Cart to render line items, totals and the address forms.
  • currencyCode defaults to 'eur' when missing — Medusa always sends it, but the fallback protects against legacy fixtures.
  • shippingAddress / billingAddress are nullable until the user fills them in; CheckoutScreen treats null as 'collect address first'.
  • discountTotal and taxTotal are computed server-side; local recomputation drifts because of region-specific tax rules.
lib/src/features/shared/cart/models/cart.dart:4 // Cart
lib/src/features/shared/cart/models/cart_item.dart // CartItem
lib/src/features/shared/checkout/models/address.dart // Address

The backend cart is the source of truth for prices — never trust the local cart's totalPrice when displaying anything monetary post-checkout-start.

Data layer

Cart Service (Medusa Store API) · core/services/cart_service.dart

Dio-backed client for every cart-shaped endpoint Medusa exposes: create cart (physical vs provision), get/update cart, add/update line items, shipping options + methods, promo codes, payment collections (full and partial), and complete cart.

  1. createPhysicalCart / createProvisionCart POST /store/carts with metadata.type and an optional region_id; the metadata is what lets the UI keep marketplace and booking carts separate.
  2. addItemToCart / updateCartItem mutate line items (qty=0 deletes server-side).
  3. applyPromoCode / removePromoCode use POST … /promotions with the new full list (Medusa-style overwrite semantics).
  4. fetchShippingOptions + addShippingMethod attach a delivery option.
  5. fetchPaymentProviders + createPaymentCollection + createPaymentSession build the Stripe path.
  6. createPartialPaymentSession splits the amount between Stripe (deposit) and 'pp_system_default' (pay-on-site rest) for booking deposits.
  7. completeCart converts the cart to an order_set after a successful payment.
  • create*Cart auto-retries without region_id on 400/422 — backend rejects unknown 'region_id' for some merchant configs; the retry lets the cart still be created.
  • Booking-question helpers (getCartItemQuestions / answer / remove) treat 404 as 'no questions configured' rather than an error.
  • completeCart accepts three possible shapes (order_set, data, {type:'order'}) because the Medusa response varies by version — the priority order matters.
  • removePromoCode currently sends an empty promo_codes list rather than the documented DELETE endpoint; backend behavior has changed across Medusa V2 minors.
lib/src/core/services/cart_service.dart:7 // CartService
lib/src/core/services/cart_service.dart:11 // createProvisionCart (+retry)
lib/src/core/services/cart_service.dart:185 // addItemToCart
lib/src/core/services/cart_service.dart:378 // applyPromoCode
lib/src/core/services/cart_service.dart:612 // createPartialPaymentSession
lib/src/core/services/cart_service.dart:663 // completeCart

Every method opts in to authScope: 'customer' via RequestTrace; never call this with a seller-only token attached.

Local Cart Storage · core/services/local_storage.dart · getLocalCartItems / save / clear

Per-user SharedPreferences slice for the local cart. Keyed by customer id so that multiple accounts on the same device keep separate carts.

  1. getLocalCartItems(userId) reads the JSON string under the user-scoped key and runs LocalCartItem.decodeList.
  2. saveLocalCartItems(userId, list) serializes via encodeList and writes the JSON string.
  3. clearLocalCartItems(userId) removes the key entirely — used by CartController.clearCart and on logout.
  • decodeList swallows JSON errors → corrupt storage resets to []. Acceptable because a busted local cart is a recoverable inconvenience.
  • Keys are not migrated when a user changes id (server merges accounts) — the old cart becomes orphaned in storage.
  • No size cap; a malicious or buggy add loop could grow the JSON beyond what SharedPreferences happily handles on Android.
lib/src/core/services/local_storage.dart:53 // getLocalCartItems
lib/src/core/services/local_storage.dart:61 // saveLocalCartItems
lib/src/core/services/local_storage.dart:70 // clearLocalCartItems

Consider moving to flutter_secure_storage when persisting any cart that includes a backend-cart id post-checkout-start. Local storage contracts (SharedPreferences): • getLocalCartItems(userId) — read 'local_cart_<userId>' → List<LocalCartItem> (decodeList tolerates corrupt JSON → [] ) — screens: cart_screen.dart, product_details_screen.dart (badge count) • saveLocalCartItems(userId, items) — write encodeList(items) JSON string — screens: cart_screen.dart, product_details_screen.dart (after addToCart / updateQuantity / removeItem) • clearLocalCartItems(userId) — remove key — screens: cart_screen.dart (clearCart) + post-completeCart in checkout_screen.dart

Promo Code Handling · cart_service.applyPromoCode / removePromoCode

Promo codes are managed exclusively on the backend cart — there is no local-cart equivalent. Apply sends the new full code list (Medusa overwrites); remove sends an empty list to clear.

  1. User enters a promo code on CartScreen or CheckoutScreen.
  2. If no backend cart yet, the UI creates one first (createPhysicalCart / createProvisionCart).
  3. applyPromoCode posts {promo_codes: [code]} → response includes the updated discountTotal.
  4. removePromoCode posts {promo_codes: []} to clear.
  5. If a promo changes the total, updatePaymentCollectionAmount may need to resync the Stripe collection.
  • Multiple stacking promo codes are technically possible — pass them all in promo_codes; UI today only supports one at a time.
  • Removing a code that was never applied is a no-op (empty → empty) — UI must not show a 'removed' toast unless the previous state had a code.
  • Discount drift vs payment-collection amount is a documented Medusa edge case — updatePaymentCollectionAmount exists specifically to recover.
lib/src/core/services/cart_service.dart:378 // applyPromoCode
lib/src/core/services/cart_service.dart:415 // removePromoCode
lib/src/core/services/cart_service.dart:536 // updatePaymentCollectionAmount

Backend evolved between Medusa V1 (DELETE per-code) and V2 (POST full list). Keep removePromoCode pinned to the V2 semantic.

Booking Questions on Line Items · cart_service.getCartItemQuestions / answer / remove

When a seller has configured booking questions for a service, those questions attach to the cart line item. Answers (single-select, multi-select, free text, number, image upload) are written back to the line item before checkout proceeds.

  1. getCartItemQuestions(cartId, lineItemId) returns the question list.
  2. UI renders a typed input per question (depending on question.type).
  3. answerCartItemQuestion posts a body shaped by type (selected_option_ids / comment / number / image_file_id).
  4. removeCartItemAnswer clears a previous answer if the user changes their mind.
  • 404 from getCartItemQuestions is treated as 'no questions for this item' — booking proceeds without blocking.
  • image_upload answers require a previously-uploaded file id; the upload itself is handled by the file/upload service, not this endpoint.
  • Questions are stored per-line-item, so duplicating the same service into the cart twice creates two independent question sets.
lib/src/core/services/cart_service.dart:727 // getCartItemQuestions
lib/src/core/services/cart_service.dart:776 // answerCartItemQuestion
lib/src/core/services/cart_service.dart:811 // removeCartItemAnswer

Cross-references the seller's booking_questions feature — the seller side defines them, the cart side records answers.

Dependency Injection layer

Cart Provider Wiring · NotifierProviders for the cart feature

Two NotifierProviders are exported: cartControllerProvider (the local cart list) and cartSelectionProvider (the selected variantIds). The backend CartService is plain — instantiated where needed; not provider-wrapped today.

  1. cartControllerProvider builds with currentCustomerIdProvider as a watched dependency.
  2. cartSelectionProvider builds with cartControllerProvider as a watched dependency.
  3. Consumers (CartScreen, CheckoutScreen, AddToCart buttons) ref.watch / ref.read these providers directly.
  4. CartService is constructed inline in CheckoutController and other consumers — no provider gate.
  • Adding a provider for CartService would let tests inject a mock — currently not done.
  • Provider disposal is implicit — they live for the app lifetime since nothing else holds a parent scope.
lib/src/features/shared/cart/controllers/cart_controller.dart:130 // cartControllerProvider
lib/src/features/shared/cart/controllers/cart_selection_controller.dart:30 // cartSelectionProvider

currentCustomerIdProvider lives in profile/personal_info_provider.dart — see the profile feature for its definition.

Cart Route · core/routing/app_router.dart · /cart

Single GoRoute pointing /cart at CartScreen, declared inside the ShellRoute so the nav bar stays visible. Not gated by a custom guard — the global auth redirect already blocks guests from logged-in routes.

  1. context.go(AppRoute.cart.path) from a home tile or top-bar icon.
  2. ShellRoute builder wraps CartScreen with AppShellScaffold.
  3. Back behavior delegates to context.pop() which lands on the previous shell route.
  • Deep-link to /cart while not logged in → redirect bounces to /login because the auth guard fires before the route builder.
  • Adding a per-tab badge for cart count would consume cartControllerProvider in app_shell_scaffold.
lib/src/core/routing/app_router.dart:378 // /cart route
lib/src/core/routing/routes.dart // AppRoute.cart

CartScreen is intentionally not a bottom-nav destination — it's reached from home tiles and product details, not from the persistent nav.

Checkout & Payment

End-to-end checkout: builds a server cart from the selected local items, collects address + shipping + answers, runs Stripe PaymentSheet, completes the cart and clears the local items. Handles promo codes and booking deposits via partial payment sessions.

Riverpod · Medusa Store API · flutter_stripe PaymentSheet

API surface

MethodEndpointPurposeAuthStatus
GET/store/payment-providers?region_id=:idResolve the payment providers wired for the cart's region.no status
POST/store/payment-collectionsOpen a payment collection for the cart.no status
POST/store/payment-collections/:idResync the collection amount after cart mutation.no status
POST/store/payment-collections/:id/payment-sessionsInitialize a payment session for a single provider; returns the Stripe client_secret.no status
POST/store/payment-collections/:id/partial/payment-sessionInitialize multi-provider partial payments for a booking deposit (deposit + remainder).no status
POST/store/carts/:cartId/set-orderFinalize the cart and convert it into a paid order.no status

Presentation layer

Checkout Screen · views/checkout_screen.dart

Wizard-like ConsumerStatefulWidget that consumes checkoutControllerProvider and composes the address, shipping, payment-method, booking-questions and policy widgets into a scrollable column. The 'Pay' button calls confirmStripePayment.

  1. On first build, calls initCheckout with the variantIds passed from CartScreen.
  2. Renders CheckoutProgressBar at the top reflecting the current step.
  3. CheckoutAddressSection drives updateShippingAddress on submit.
  4. CheckoutShippingOptions calls setShippingOption.
  5. CheckoutPaymentMethods calls setPaymentProvider.
  6. CheckoutBookingQuestions calls setQuestionAnswer per field.
  7. Pay button → confirmStripePayment(context, onSuccess) → on success closes to /orders.
  • CheckoutExitDialog confirms before back-navigation when state.checkoutCartId != null (data loss warning).
  • If state.error is non-null, the screen renders an inline banner with retry buttons.
  • Re-entry from order history or notifications must NOT reuse stale state — the screen relies on Riverpod auto-disposing the notifier when removed from the tree (which it currently does NOT — see provider note).
lib/src/features/shared/checkout/views/checkout_screen.dart
lib/src/features/shared/checkout/views/widgets/checkout_progress_bar.dart
lib/src/features/shared/checkout/views/widgets/checkout_exit_dialog.dart

checkoutControllerProvider is a NotifierProvider (not autoDispose) — state survives screen pops. If you change this, the resume-after-back flow needs to be re-tested end-to-end.

Checkout Step Widgets · views/widgets/* (address / shipping / payment / questions / policy)

Five composable sections rendered by CheckoutScreen: address form, shipping option picker, payment method picker, booking question form, and the read-only booking policy block.

  1. checkout_address_section.dart: form + submit → updateShippingAddress(Address).
  2. checkout_shipping_options.dart: lists state.availableShippingOptions → setShippingOption.
  3. checkout_payment_methods.dart: lists state.availablePaymentProviders → setPaymentProvider.
  4. checkout_booking_questions.dart: renders per-question UI by q['type'] → setQuestionAnswer.
  5. checkout_booking_policy.dart: read-only display of bookingPolicy from ProvisionService.
  • Booking-question types: 'single_select' / 'multi_select' / 'yes_no' (use selected_option_ids), 'free_text' (comment), 'number' (number), 'image_upload' (image_file_id).
  • Required-question validation is deferred to confirmStripePayment — the widgets themselves do not block submission.
  • Shipping options can come back empty when address is missing or unsupported — UI must guide the user back to address.
lib/src/features/shared/checkout/views/widgets/checkout_address_section.dart
lib/src/features/shared/checkout/views/widgets/checkout_shipping_options.dart
lib/src/features/shared/checkout/views/widgets/checkout_payment_methods.dart
lib/src/features/shared/checkout/views/widgets/checkout_booking_questions.dart
lib/src/features/shared/checkout/views/widgets/checkout_booking_policy.dart

Each widget is dumb (props in, callback out) — state lives in CheckoutController only, no per-widget Riverpod providers.

Payment Success Dialog · views/widgets/payment_success_dialog.dart

Modal shown the moment PaymentSheet returns success, while completeCart is still in flight. Displays the (still-cart-id-shaped) order id and blocks user input until the order is finalized.

  1. showDialog(barrierDismissible: false) is awaited via dialogFuture before the onSuccess callback fires.
  2. completeCart happens INSIDE the dialog's visible window so the user perceives instant success.
  3. Calendar provider.refresh is triggered to update any visible booking grids.
  • If completeCart fails AFTER PaymentSheet success, money is captured but order is not finalized — dialog stays visible but onSuccess never fires; the snackbar 'Error: $e' surfaces underneath.
  • Cart id is shown as 'order id' to the user — the actual order id is only available after completeCart, but UX prefers an immediate ack.
lib/src/features/shared/checkout/views/widgets/payment_success_dialog.dart
lib/src/features/shared/checkout/controllers/checkout_controller.dart:378 // showDialog

The dialog is dismissed by the controller's onSuccess callback, not by user tap — pulling barrierDismissible to true would break the await flow.

Domain layer

CheckoutState · models/checkout_state.dart

Immutable snapshot of the entire checkout flow: backend cart id + Cart, Stripe clientSecret, selected shipping option / payment provider, promo code, available providers, booking questions + answers, plus the global isLoading / error flags.

  1. Constructed empty in CheckoutController.build().
  2. copyWith re-issues every field except error which is intentionally pass-through (so passing error: null clears it).
  3. Almost every controller method does state.copyWith(isLoading: true) on entry and ...(isLoading: false) on exit.
  • removePromoCode rebuilds the state via the full constructor (not copyWith) specifically so it can null out promoCode — copyWith's ?? would keep the old code.
  • questionAnswers and bookingQuestions default to const [] / const {} so the screen can render before initCheckout returns.
  • availableShippingOptions and availablePaymentProviders are List<dynamic> — typed payment_provider.dart and shipping_option.dart exist but the controller still passes the raw maps through.
lib/src/features/shared/checkout/models/checkout_state.dart:3 // CheckoutState
lib/src/features/shared/checkout/models/checkout_state.dart:34 // copyWith

Booking-questions live here rather than in a dedicated notifier because validating them must happen at the same instant as confirming payment — splitting state across notifiers would create a race.

CheckoutController · controllers/checkout_controller.dart · Notifier<CheckoutState>

Drives the entire checkout pipeline. Holds a CartService + ProvisionService directly (no DI). Each step (init, address, shipping, payment) mutates CheckoutState via copyWith and exposes a 'shallow-cart safeguard' that refetches the full cart if Medusa returns a stripped response.

  1. initCheckout: createPhysicalCart → addItemToCart × N → getCart → savePendingCheckout → fetchPaymentProviders + fetchShippingOptions → ProvisionService.getProvisionQuestions/Policy.
  2. updateShippingAddress: updateCart with shipping_address + email, then fetchShippingOptions for that address.
  3. setShippingOption / setPaymentProvider: store choice in state (paymentProvider does NOT call backend).
  4. applyPromoCode / removePromoCode: deferred to CartService; re-fetch full cart on shallow response.
  5. createPaymentSession: createPaymentCollection → updatePaymentCollectionAmount if the cart total drifted → createPaymentSession(providerId) → pull clientSecret out of payment_sessions list.
  6. confirmStripePayment: validate required questions → updateCart with booking_answers → init+present PaymentSheet → completeCart → addOrderToHistory → clear local cart entries → clearPendingCheckout.
  • Shallow-cart safeguard repeats after every mutating call (updateCart, addShippingMethod, applyPromoCode, removePromoCode) because Medusa sometimes returns the cart without items inlined; we detect by 'previous had items, response has none' and refetch.
  • Payment-collection amount drift is reconciled by comparing the existing collection amount with (cart.total * 100) cents and PATCH-ing if needed.
  • Booking-question validation runs IN confirmStripePayment, not earlier — required because answers can change up until the moment the user taps Pay.
  • StripeException.FailureCode.Canceled is treated as a soft outcome (snack 'Payment Cancelled') rather than an error toast.
  • PaymentSheet flow is wrapped in try/catch with a Stripe-specific branch — non-Stripe errors fall through to a generic 'Error: $e' snack.
  • TODO in updateShippingAddress: email is hard-coded to 'user@example.com'; this currently breaks email receipts for production users.
lib/src/features/shared/checkout/controllers/checkout_controller.dart:16 // CheckoutController
lib/src/features/shared/checkout/controllers/checkout_controller.dart:27 // initCheckout
lib/src/features/shared/checkout/controllers/checkout_controller.dart:243 // createPaymentSession (amount sync)
lib/src/features/shared/checkout/controllers/checkout_controller.dart:308 // confirmStripePayment
lib/src/features/shared/checkout/controllers/checkout_controller.dart:437 // checkoutControllerProvider

Side-effect-heavy method (confirmStripePayment) takes BuildContext and a callback — keep it untestable in isolation; tests are integration-only for now.

Address · models/address.dart

Mirrors Medusa's shipping/billing address shape: first_name, last_name, address_1/2, city, postal_code, province, country_code, phone and a metadata bag.

  1. Address.fromJson maps the snake-case Medusa payload into camelCase fields.
  2. toJson re-emits snake-case for updateCart's shipping_address body.
  3. Empty-string defaults for missing required fields ensure fromJson never throws on partial backend responses.
  • metadata is opaque — used by the backend to flag 'address pre-filled from profile' vs 'manually entered', not by the client.
  • countryCode optional, but Medusa shipping options key off it — missing it usually returns an empty shipping_options list.
lib/src/features/shared/checkout/models/address.dart:1 // Address
lib/src/features/shared/checkout/models/address.dart:30 // Address.fromJson
lib/src/features/shared/checkout/models/address.dart:47 // toJson

Used by both Cart.shippingAddress / billingAddress and the address picker widget in CheckoutScreen.

Data layer

Cart Payment Endpoints · core/services/cart_service.dart (payment + complete)

Subset of CartService used by checkout: payment-providers, payment-collection, full / partial payment-session, completeCart. The booking-deposit path uses createPartialPaymentSession to split between Stripe and pay-on-site.

  1. fetchPaymentProviders(regionId) → GET /store/payment-providers.
  2. createPaymentCollection(cartId) → POST /store/payment-collections.
  3. updatePaymentCollectionAmount(id, amount) → resync when discounts change.
  4. createPaymentSession(collectionId, providerId) → full-amount session, returns payment_sessions[].data.client_secret.
  5. createPartialPaymentSession(collectionId, deposit, rest) → splits providers (pp_partial_stripe-connect, pp_system_default).
  6. completeCart(cartId) → POST /store/carts/{id}/set-order; returns the order_set wrapping the resulting orders.
  • completeCart accepts three response shapes (order_set / data / {type:'order'}) — Medusa versioning, do not reduce.
  • createPartialPaymentSession amounts are double-truncated to 2dp before send to avoid Stripe rejecting a floating-point mismatch.
  • Provider IDs are stringly typed — magic strings 'pp_system_default' and 'pp_partial_stripe-connect' must match backend config exactly.
lib/src/core/services/cart_service.dart:463 // fetchPaymentProviders
lib/src/core/services/cart_service.dart:500 // createPaymentCollection
lib/src/core/services/cart_service.dart:570 // createPaymentSession
lib/src/core/services/cart_service.dart:612 // createPartialPaymentSession
lib/src/core/services/cart_service.dart:663 // completeCart

These endpoints live in CartService — see the cart feature for the broader API surface; this node names the slice that checkout owns.

Stripe PaymentSheet · package:flutter_stripe · Stripe.instance

Direct integration with flutter_stripe's PaymentSheet. CheckoutController hands a clientSecret to initPaymentSheet and presentPaymentSheet handles every UI/UX detail (card entry, Apple Pay, 3DS).

  1. Stripe.publishableKey is set once in main.dart from STRIPE_PUBLISHABLE_KEY (.env).
  2. createPaymentSession returns a clientSecret stored in CheckoutState.
  3. initPaymentSheet receives the clientSecret + merchantDisplayName.
  4. presentPaymentSheet awaits user interaction; success → continue, cancel → StripeException(Canceled).
  • Style is fixed to ThemeMode.light — even when the app is in dark mode the sheet stays light (Stripe limitation we accepted).
  • Cancellation surfaces as a SnackBar, not a state.error — the user can simply retap Pay.
  • Re-presenting the sheet after a network error requires a fresh clientSecret — re-call createPaymentSession.
lib/main.dart // Stripe.publishableKey
lib/src/features/shared/checkout/controllers/checkout_controller.dart:360 // initPaymentSheet
lib/src/features/shared/checkout/controllers/checkout_controller.dart:371 // presentPaymentSheet

flutter_stripe is imported with `hide Address` so its `Address` type doesn't collide with the local Address model — leave the import as-is. Native SDK contracts (no HTTP; embedded native plugin): • Stripe.instance.initPaymentSheet(SetupPaymentSheetParameters { paymentIntentClientSecret, merchantDisplayName, style: ThemeMode.light }) → void — screen: lib/src/features/shared/checkout/views/checkout_screen.dart • Stripe.instance.presentPaymentSheet() → success | StripeException(FailureCode.Canceled | …) — screen: lib/src/features/shared/checkout/views/checkout_screen.dart • Pre-step: read clientSecret from CheckoutState (populated by /store/payment-collections/{id}/payment-sessions response).

Pending Checkout + Order History · core/services/local_storage.dart · pending checkout + order history

SharedPreferences slice for recovery: savePendingCheckout (after backend cart creation), clearPendingCheckout (on success), addOrderToHistory (after completeCart). Lets the app reopen a half-finished checkout when relaunched mid-payment.

  1. After initCheckout: savePendingCheckout(cartId, selectedVariantIds).
  2. After successful completeCart: addOrderToHistory(cartId) and clearPendingCheckout.
  3. Order history is read by the orders feature to render the user's purchase list independent of backend availability.
  • If the app dies between createPaymentSession and presentPaymentSheet, the saved pending-checkout lets us resume on next launch — but only if the backend cart hasn't been cleaned up server-side.
  • addOrderToHistory only stores the cart/order id; full order data is re-fetched via OrderService on demand.
  • Multiple pending checkouts overwrite each other — the implementation is single-slot.
lib/src/core/services/local_storage.dart // savePendingCheckout / clearPendingCheckout / addOrderToHistory
lib/src/features/shared/checkout/controllers/checkout_controller.dart:57 // savePendingCheckout
lib/src/features/shared/checkout/controllers/checkout_controller.dart:393 // addOrderToHistory

Multi-cart resume is not supported by design — assumption is the user finishes (or abandons) one checkout at a time. Local storage contracts (SharedPreferences — no HTTP): • savePendingCheckout({ cartId, selectedVariantIds }) — write keys 'pending_checkout_cart_id' + 'pending_checkout_variant_ids' — screen: lib/src/features/shared/checkout/views/checkout_screen.dart (after initCheckout creates the backend cart) • clearPendingCheckout() — remove keys — screen: checkout_screen.dart (post-completeCart success) • addOrderToHistory(cartOrOrderId) — append to 'order_history' list — screen: checkout_screen.dart (after completeCart) — consumed by lib/src/features/shared/orders/views/orders_screen.dart for offline fallback

Dependency Injection layer

Checkout Provider · checkoutControllerProvider · NotifierProvider

Single NotifierProvider for the entire checkout. No autoDispose, so state survives screen pops and lets the resume-from-pending-checkout flow work. CartService and ProvisionService are instantiated inside the notifier — not provider-wrapped.

  1. CheckoutScreen ref.watch(checkoutControllerProvider) on every state change.
  2. CheckoutController constructs CartService() and ProvisionService() in field initializers.
  3. Other consumers (orders feature) ref.read this provider only after a completed checkout to read state.checkoutCart.
  • Lack of autoDispose means a stale CheckoutState lingers if the user starts a checkout, leaves the app, and starts a new flow days later — the controller does NOT auto-reset.
  • Inline service construction is fine for prod but means tests can't inject mocks without overrideWith.
lib/src/features/shared/checkout/controllers/checkout_controller.dart:437 // checkoutControllerProvider

Switching to AutoDisposeNotifierProvider would simplify reasoning but break the resume-after-back flow — make the swap deliberate.

Orders

Dual-role order listing and detail: customers see /store/orders, sellers see /vendor/orders with embedded customer + split-payment data. Includes PDF receipt generation and a seller cancel path that triggers a full refund.

Riverpod · Medusa Store + Vendor API · pdf

API surface

MethodEndpointPurposeAuthStatus
GET/store/ordersCustomer order list (newest-first).no status
GET/store/orders/:orderIdCustomer order detail.no status
GET/vendor/orders?fields=*customer,+payment_status,*split_order_payment&status=:statusSeller order list with embedded customer + split-payment info.no status
GET/vendor/orders/:orderId?fields=*customer,+payment_status,*split_order_paymentSeller order detail with customer + split-payment.no status
POST/vendor/orders/:orderId/cancelSeller-initiated order cancellation and refund.no status

Presentation layer

Orders Screen · views/orders_screen.dart

Single screen with role-aware tabs. Customers see All / Pending / Fulfilled / Delivered. Sellers see All / Pending / Canceled. The active provider (customer vs seller) is selected by userRoleProvider.

  1. _OrdersScreenState builds a TabController with 4 tabs.
  2. build watches userRoleProvider and the matching list provider.
  3. _filterOrders applies a per-tab status filter against the list returned by the provider.
  4. Pull-to-refresh calls the matching controller's refreshOrders.
  • isSeller branch uses the seller filter set (pending / canceled) — customer branch uses fulfillment buckets (pending / shipped / delivered).
  • Empty-state messaging differs per role — sellers see 'no pending orders', customers see 'no purchases yet'.
  • TabController length is hard-coded to 4 — adding a 5th tab requires changing the ctor.
lib/src/features/shared/orders/views/orders_screen.dart:16 // OrdersScreen
lib/src/features/shared/orders/views/orders_screen.dart:41 // _filterOrders

Tab order is fixed in code — the labels come from S.current.* localizations, so changing copy is a single ARB change.

Order Detail Screen · views/order_detail_screen.dart

Shows a single Order. For customers: items, tracking timeline, shipping address, totals, 'view receipt' CTA. For sellers: also customer block, split-payment status, Cancel button with confirmation dialog.

  1. Reads orderId from the GoRouter path param.
  2. Consumes orderDetailProvider(orderId).
  3. Customer flow: TrackingTimeline shows fulfillmentStatus progression; Receipt button pushes ReceiptScreen.
  4. Seller flow: Cancel button → confirmation dialog → cancelSellerOrderProvider(orderId).future → re-fetch detail.
  5. Error / loading states are rendered via AsyncValue.when().
  • Cancelled orders disable the Cancel button and surface 'Refunded €X' computed from splitOrderPayment.refundedAmount.
  • Tracking timeline interprets fulfillment_status into steps: pending → fulfilled → shipped → delivered.
  • Seller has no 'view receipt' (no CTA implemented yet) — that's a customer-only flow.
lib/src/features/shared/orders/views/order_detail_screen.dart
lib/src/features/shared/orders/views/widgets/tracking_timeline.dart

Long screen (~1k lines) with inline subwidgets — pulling them out into widgets/* would simplify cross-role testing.

Order Card + Status Badge · views/widgets/order_card.dart + order_status_badge.dart

List-row widget rendering one order. Customer variant emphasises seller info + tracking; seller variant emphasises customer info + split-payment status badge.

  1. OrderCard receives an Order and a tap callback (push detail).
  2. OrderStatusBadge color-maps Medusa statuses (pending → amber, captured → green, refunded → grey, canceled → red).
  3. Tapping the card calls context.pushNamed(AppRoute.orderDetail, orderId).
  • Status badge falls back to 'unknown' grey for any string not in the map — protects against new server statuses.
  • Localization is per-status via S.current.statusXxx; missing keys render the raw string.
lib/src/features/shared/orders/views/widgets/order_card.dart
lib/src/features/shared/orders/views/widgets/order_status_badge.dart
lib/src/features/shared/orders/views/widgets/tracking_timeline.dart

Card width assumes ~390 logical px phone — tablets need a Wrap container at the screen level to avoid stretched rows.

Domain layer

Order Model + Sub-entities · models/order.dart · Order, OrderItem, OrderAddress, OrderCustomer, SplitOrderPayment

Unified Order shape that absorbs both the Medusa /store/orders response and the seller /vendor/orders response. Customer-facing fields (sellerName/Phone/Photo, startTime/endTime, shipping methods) sit alongside seller-facing fields (customer block, splitOrderPayment).

  1. Order.fromJson reads the Medusa payload and conditionally populates customer + splitOrderPayment when those keys exist (seller response).
  2. OrderItem nests variant info needed by OrderCard.
  3. OrderAddress carries shipping + billing addresses.
  4. SplitOrderPayment surfaces status / authorized / captured / refunded for seller-side UIs.
  5. displayIdFormatted formats the human-readable id used in logs and the receipt.
  • fromJson is intentionally lenient — parse errors on a single order are caught upstream (in OrderService) and that order is dropped from the list rather than aborting the whole load.
  • Seller payload includes product_type='provision' on items which the UI can use to differentiate physical vs booking orders.
  • Some customer-side fields (seller info, startTime/endTime) are derived from order metadata.slot — null is normal for non-booking orders.
lib/src/features/shared/orders/models/order.dart:3 // Order
lib/src/features/shared/orders/models/order.dart // OrderItem / OrderAddress / OrderCustomer / SplitOrderPayment

The CLAUDE.md inside this feature folder documents the seller-orders rollout plan — keep new fields in fromJson tolerant of both response shapes.

Customer Order Controller · controllers/order_controller.dart · AsyncNotifier<List<Order>>

AsyncNotifier that loads the customer's orders on build and exposes refreshOrders. State is AsyncValue<List<Order>> so the screen can render loading / error / data without manual flags.

  1. build() returns OrderService.getOrders() — Riverpod caches the result.
  2. refreshOrders sets state to AsyncValue.loading, then re-runs getOrders inside AsyncValue.guard for typed error capture.
  3. OrdersScreen ref.watches the provider and re-renders on every state transition.
  • Network failure on build surfaces as AsyncValue.error; the screen shows a retry button that calls refreshOrders.
  • List is reversed by OrderService (newest first) — the controller does not re-sort.
  • No pagination yet — getOrders returns whatever the backend default limit is (50).
lib/src/features/shared/orders/controllers/order_controller.dart:7 // OrderController
lib/src/features/shared/orders/controllers/order_controller.dart:23 // orderControllerProvider

Use ref.invalidate(orderControllerProvider) from completing checkout to force a refresh — the controller is not auto-invalidated.

Seller Order Controller · SellerOrderController + cancelSellerOrderProvider

Mirror of the customer controller but hitting /vendor/orders with the seller token. cancelSellerOrderProvider (FutureProvider.family<Order, String>) cancels an order then ref.invalidates the seller list to force a refresh.

  1. build() returns OrderService.getSellerOrders() (no status filter by default).
  2. refreshOrders is the same loading→guard pattern as the customer controller.
  3. cancelSellerOrderProvider(orderId) → OrderService.cancelSellerOrder + ref.invalidate(sellerOrderControllerProvider).
  • Cancellation is always allowed server-side (no policy gate) — UI must own the confirmation dialog.
  • After cancel, splitOrderPayment.status flips to 'refunded' — the screen should re-fetch the detail to reflect this.
  • Status query param is supported (getSellerOrders({status: 'pending'})) but the controller doesn't expose it yet — UI filters client-side.
lib/src/features/shared/orders/controllers/order_controller.dart:43 // SellerOrderController
lib/src/features/shared/orders/controllers/order_controller.dart:59 // sellerOrderControllerProvider
lib/src/features/shared/orders/controllers/order_controller.dart:65 // cancelSellerOrderProvider

Seller endpoints are documented in lib/src/features/shared/orders/CLAUDE.md including the response shapes — keep that doc up to date when you add filters or actions.

Order Detail Provider · orderDetailProvider · FutureProvider.family<Order, String>

Single-order fetcher that watches userRoleProvider and routes to /vendor/orders/{id} for sellers, /store/orders/{id} for customers. Same family pattern is consumed by both customer and seller detail screens.

  1. ref.watch(userRoleProvider) → isSeller flag.
  2. OrderService.getOrder(orderId, isSeller: isSeller) hits the right endpoint with the right field selection.
  3. Order goes into the FutureProvider.family cache keyed by orderId.
  • Role-switch in-session invalidates the cache for this family key — the next read refetches via the right endpoint.
  • Seller endpoint adds the query fields='*customer,+payment_status,*split_order_payment' to inline data the seller UI needs.
lib/src/features/shared/orders/controllers/order_controller.dart:29 // orderDetailProvider
lib/src/core/services/order_service.dart:44 // getOrder(isSeller)

Family providers leak entries unless invalidated — call ref.invalidate(orderDetailProvider(orderId)) after mutations.

Data layer

Order Service · core/services/order_service.dart

Four endpoints: getOrders (customer list), getOrder (single, role-aware), getSellerOrders (vendor list with field selection), cancelSellerOrder (vendor POST cancel).

  1. getOrders → GET /store/orders with authScope:'customer'.
  2. getOrder routes path by isSeller flag and attaches the seller field-selection query.
  3. getSellerOrders → GET /vendor/orders?fields=… + optional status filter.
  4. cancelSellerOrder → POST /vendor/orders/{id}/cancel.
  5. All list responses parse orders one at a time and skip-on-error to avoid one bad row killing the whole list.
  • Per-order parse failures are debugPrinted and the row is dropped — they never crash the controller.
  • Reversed list ordering is applied here (newest first) — controllers and screens rely on this invariant.
  • Seller endpoints rely on the DioHelper path heuristic (/vendor/* → seller token) to attach the right Authorization header.
lib/src/core/services/order_service.dart:12 // getOrders
lib/src/core/services/order_service.dart:44 // getOrder
lib/src/core/services/order_service.dart:76 // getSellerOrders
lib/src/core/services/order_service.dart:117 // cancelSellerOrder

Each call carries a RequestTrace with feature='orders/customer' or 'orders/seller' — useful when grep-debugging real network traces.

PDF Receipt Service · services/pdf_receipt_service.dart

Generates a PDF receipt from an Order using the `pdf` package — order summary, line items, totals, addresses, optional seller block. Saves to temp + offers share/print via the platform sheet.

  1. Service is instantiated per call (no provider).
  2. buildPdf(order) composes a multi-page document with pw.Document().
  3. savePdf writes to a temp file via path_provider; sharePdf forwards to Share.shareXFiles.
  • Long item lists wrap across pages — pageBreak is automatic via the pdf package, no manual splitting.
  • RTL languages render through the default font — Arabic shaping is approximate, fine for printable receipts but not for layout-perfect typography.
  • Service does not embed images today — seller photo URLs render as text-only fields.
lib/src/features/shared/orders/services/pdf_receipt_service.dart
lib/src/features/shared/orders/views/receipt_screen.dart

Receipt regeneration must be idempotent — sharing the same order twice should produce byte-identical PDFs (modulo timestamps). No HTTP contract — operates purely on the in-memory Order entity. File output contracts: • savePdf(order) → File at <appCache>/receipt_<orderId>.pdf — screen: lib/src/features/shared/orders/views/receipt_screen.dart • sharePdf(file) → Share.shareXFiles([file]) (platform share sheet) — screen: lib/src/features/shared/orders/views/receipt_screen.dart

Dependency Injection layer

Orders Routes · core/routing/app_router.dart · /orders + /orders/:orderId

Two GoRoutes inside the ShellRoute: /orders → OrdersScreen, /orders/:orderId → OrderDetailScreen. Both are role-aware at render time, not at routing time — the auth guard only checks isLoggedIn.

  1. Home grid tile 'Panier' / 'Orders' navigates to /orders.
  2. OrderCard taps push /orders/:orderId with the path param.
  3. Detail screen reads pathParameters['orderId'] and feeds orderDetailProvider.
  • Deep-linking from a notification (FCM 'new order') jumps directly to /orders/:orderId — make sure orderDetailProvider can recover from a cold-start cache miss.
  • No nested ShellRoute — the bottom nav stays visible on detail.
lib/src/core/routing/app_router.dart:613 // /orders
lib/src/core/routing/app_router.dart:619 // /orders/:orderId

Receipt screen is pushed (not routed) from order detail — keep it out of the GoRouter table to avoid making it deep-linkable.

Marketplace (Physical Products)

Paginated product catalog with combined server + client-side filtering: categories, tags, variant option values (color/size), title search and four sort orders. Backs the home tile 'Marketplace' for clients.

Riverpod · Medusa Store API · Paginated lists

API surface

MethodEndpointPurposeAuthStatus
GET/store/physical-productsPaginated catalog of physical products with combined server-side filter, sort and search.no status
GET/store/physical-products/:productId?fields=*variants.calculated_priceFetch a single physical product (fallback when route extra is missing).no status

Presentation layer

Marketplace Home Screen · views/marketplace_home_screen.dart

Composite scrollable screen made of a banner carousel, category strip, trending deals, summer-sale tile, special offers, and the main product grid/list. Watches marketplaceControllerProvider for the list state.

  1. Top bar with search + cart + notification triggers.
  2. MarketplaceCategoryList chips toggle category filters.
  3. MarketplaceFilterBar exposes Filter / Sort / Grid toggles via bottom sheets.
  4. MarketplaceProductList renders the result and triggers loadMore on near-bottom scroll.
  5. warningMessage banner shows after auto-recovery and is dismissible via clearWarning.
  • Pagination trigger fires when scroll position passes ~80% — tweak in the list widget, not the controller.
  • Hero widgets (banners, summer sale) read from local sample data; they are not yet backed by a CMS service.
  • Grid vs list toggle persists for the controller lifetime (not across kills).
lib/src/features/shared/marketplace/views/marketplace_home_screen.dart
lib/src/features/shared/marketplace/views/widgets/marketplace_banner_carousel.dart
lib/src/features/shared/marketplace/views/widgets/marketplace_special_offers.dart

Long screen (~540 lines) — keep new sections as standalone widgets in widgets/ to avoid the file ballooning further.

Product List Widget · widgets/marketplace_product_list.dart

Switches between grid and list layout based on state.isGridView. Each tile is tappable (pushes /product-details/:id), shows the calculated price, and renders a favorite toggle (controller.toggleFavorite).

  1. ListView/GridView builds from state.products.
  2. Near-bottom scroll → controller.loadMore.
  3. Heart icon tap → controller.toggleFavorite(productId).
  4. Tile tap → context.push(AppRoute.productDetails, extra: product).
  • Empty state renders an illustration + 'Reset filters' CTA when filters are active.
  • Grid uses crossAxisCount=2 fixed — tablets need a media-query override.
  • Skeleton loading rows are shown for state.isLoading while products.isNotEmpty for smooth append.
lib/src/features/shared/marketplace/views/widgets/marketplace_product_list.dart

Tap routes to AppRoute.productDetails passing the Product via state.extra — see app_router.dart for the typed cast.

Filter / Sort Bar · widgets/marketplace_filter_bar.dart

Horizontal bar with three primary chips: Filter (opens FilterBottomSheet), Sort (SortByBottomSheet), and a Grid/List toggle. Selected filter count is displayed as a badge on the Filter chip.

  1. Tap Filter → showModalBottomSheet(FilterBottomSheet) → applyCompositeFilters.
  2. Tap Sort → showModalBottomSheet(SortByBottomSheet) → setSortOrder.
  3. Tap Grid/List → controller.toggleViewType.
  4. Active filters are reflected as small chip indicators next to the bar.
  • Filter badge count is computed from state.categoryIds.length + state.tagIds.length + state.variantOptionValues.length — order changes if you re-order the filter dimensions.
  • Sort by 'price_asc'/'price_desc' is client-side; 'created_at' variants are server-side — bar doesn't distinguish.
lib/src/features/shared/marketplace/views/widgets/marketplace_filter_bar.dart

Consider centralizing filter-count math in the controller as a derived getter.

Filter / Sort / Category / Location Bottom Sheets · widgets/bottom_sheets/*

Four bottom sheets driving filter and sort interactions. Filter sheet renders the FilterSection list, Sort sheet picks the sort key, Category sheet is a quick category picker, Location sheet (currently mocked) picks the delivery region.

  1. FilterBottomSheet builds from state.availableFilters and emits selected IDs.
  2. SortByBottomSheet lists 4-5 sort options with the current one highlighted.
  3. CategoryBottomSheet is a vertical category list used outside the filter sheet.
  4. LocationBottomSheet is currently a placeholder; tied to a future region-aware fetch.
  • Filter sheet reads available filters from the controller — sheet-only state is local until 'Apply' is tapped.
  • Sort sheet calls setSortOrder which forks between client- and server-side sorting.
  • Location sheet does not yet wire into MarketplaceService — backend doesn't accept a region filter in this endpoint yet.
lib/src/features/shared/marketplace/views/widgets/bottom_sheets/filter_bottom_sheet.dart
lib/src/features/shared/marketplace/views/widgets/bottom_sheets/sort_by_bottom_sheet.dart
lib/src/features/shared/marketplace/views/widgets/bottom_sheets/category_bottom_sheet.dart
lib/src/features/shared/marketplace/views/widgets/bottom_sheets/location_bottom_sheet.dart

Sheet 'Reset' buttons map onto controller.clearAllFilters — they should NOT manually rebuild state.

Domain layer

Product Model · models/product.dart · Product + Variant + Option + Category

Mirrors Medusa's physical product shape: id, title, description, thumbnail, images, options (Color/Size/etc.), variants (with calculated_price), categories, tags, plus a client-only isFavorite flag for the heart toggle.

  1. Product.fromJson parses the response from /store/physical-products.
  2. Variant.calculatedPrice carries the per-region price the UI displays.
  3. copyWith(isFavorite) is used by toggleFavorite without mutating the original.
  • isFavorite is local-only — it doesn't round-trip to the backend in this feature (wishlist persistence lives in another service).
  • calculated_price requires the `fields=*variants.calculated_price` query param — without it, variants render at 0.
  • options & variants are independent: options describe the schema (Color/Size), variants are the realized SKU combinations.
lib/src/features/shared/marketplace/models/product.dart // Product, Variant, Option, Category

The same Product class is reused by the provision marketplace — booking services are modeled as 'products' with metadata.type='provision'.

Marketplace Controller + State · controllers/marketplace_controller.dart · Notifier<ProductListState>

Notifier owning the entire paged + filtered list. ProductListState carries loading flags, products, pagination cursor (offset, hasMore), every filter dimension, and the available-filter set computed from page 0.

  1. build() schedules a Future.microtask(refresh) so the initial fetch fires on first watch.
  2. _fetchProducts respects offset and limit=20; the response is concatenated with previous pages.
  3. Sort orders 'price_asc' / 'price_desc' are applied client-side; everything else is server-side (passed as `order=` param).
  4. Available filters (Category, Colors, Size) are extracted from page 0 results via _extractFilters and cached.
  5. toggleCategory / toggleTag / toggleVariantOption flip an item in the corresponding list, then refresh.
  • Medusa's variants[options][value] only accepts a single value — the controller sends the first selected option to the API and filters the rest client-side.
  • Filter-error auto-recovery: if a filtered fetch throws and any filter is active, all filters reset, warningMessage is set, and refresh fires unfiltered.
  • copyWith does not allow clearing warningMessage; clearWarning rebuilds the state explicitly with warningMessage: null.
  • Local price sort is applied on top of paged results — the order across page boundaries is unstable when more items load.
  • toggleVariantOption can't enforce per-group single-select (Color vs Size) without group knowledge — the UI must pre-process to send 'one Color + one Size'.
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:13 // ProductListState
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:116 // MarketplaceController
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:145 // _fetchProducts
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:282 // clearWarning hack
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:432 // _extractFilters

The 100+ lines of inline comments inside ProductListState.copyWith document a real frustration with warningMessage clearing — read those before refactoring.

Filter Models · models/filter_data.dart + filter_sample_data.dart

FilterSection (label + options[]) and FilterOption (label, id, isSelected). filter_sample_data.dart seeds the default sections (Category / Colors / Size / Brand etc.) that _extractFilters then refines from the live product list.

  1. filterSections seeds the empty state shown before any products load.
  2. _extractFilters merges live category/color/size values from page 0 into the seed sections.
  3. Bottom sheets render the FilterSection list and emit selected IDs back to the controller.
  • Default 'All' Category option (isSelected: true, id: '') is prepended only when categoryMap is non-empty.
  • Colors and Size are detected by case-insensitive title contains — fragile if a seller introduces a different option naming.
lib/src/features/shared/marketplace/models/filter_data.dart
lib/src/features/shared/marketplace/models/filter_sample_data.dart

FilterOption.id is the only payload sent to the server — labels are presentation-only and must not be trusted as identifiers.

Data layer

Marketplace Service · core/services/marketplace_service.dart

Two endpoints: paginated/filtered list and single-product fetch. Both hit /store/physical-products with `fields=*variants.calculated_price` so the UI gets per-region pricing in the same response.

  1. getProducts(...filters) → GET /store/physical-products with the dynamic query map.
  2. Empty / null filters are omitted via the spread + if-condition pattern.
  3. getProductById(id) → GET /store/physical-products/:id; throws 'Product not found' for null data.
  • Network errors are unwrapped (response.data['message']) when possible, then re-thrown as Exception for the controller to surface.
  • Each filter list field uses Dio's auto-encoding (?category_id=X&category_id=Y) — relies on Dio's default ListFormat.
  • title[$like] is escaped with a literal '$' in the query key — Medusa accepts that as the contains operator.
lib/src/core/services/marketplace_service.dart:9 // getProducts
lib/src/core/services/marketplace_service.dart:78 // getProductById

Pagination is via offset+limit; no cursor / nextLink — the controller increments offset by the actual response length, not by limit, so a partial last page still terminates cleanly.

Dependency Injection layer

Marketplace Provider Wiring · marketplaceServiceProvider + marketplaceControllerProvider

marketplaceServiceProvider provides a singleton MarketplaceService; marketplaceControllerProvider provides the Notifier. ProductListState is the consumed value.

  1. Controller.build() does ref.watch(marketplaceServiceProvider) — if you override the service provider in tests, the controller picks it up.
  2. No autoDispose — state survives screen pops so the user returns to their scroll position + filters.
  • Switching to autoDispose would lose pagination on a back-and-forth — current behavior is intentional.
  • Service is reconstructed on every provider rebuild because the constructor is a plain `MarketplaceService()` — harmless since Dio is a singleton.
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:8 // marketplaceServiceProvider
lib/src/features/shared/marketplace/controllers/marketplace_controller.dart:427 // marketplaceControllerProvider

Tests that swap the service must do so via ProviderScope override; instantiating MarketplaceController directly bypasses Riverpod and is brittle.

Marketplace Routes · core/routing/app_router.dart · /marketPlace + /product/:id

/marketPlace points at MarketplaceHomeScreen inside the ShellRoute. Product detail uses AppRoute.productDetails and reads `state.extra as Product` to avoid a refetch on tile-tap.

  1. Home grid tile 'Marketplace' (client) → context.push(/marketPlace).
  2. MarketplaceProductList tile tap → context.push(/product-details, extra: product).
  3. ProductDetailScreen reads the Product directly from extra — does not call getProductById on entry.
  • Deep-linking to /product-details/:id without extra is unsupported today — the route builder casts state.extra to Product unconditionally.
  • The marketPlace path uses camel-case in AppRoute — keep it stable; downstream URLs depend on it.
lib/src/core/routing/app_router.dart:494 // /marketPlace
lib/src/core/routing/app_router.dart:499 // /product-details
lib/src/core/routing/routes.dart // AppRoute.marketPlace

If product details ever needs to be a real URL (sharable link), refactor to read pathParameters['id'] and call getProductById on entry.

Notifications & Push

End-to-end push pipeline: FCM token sync to backend (per-role), foreground-message local notification fallback, and an in-app notification list that merges customer + seller streams from /social/fcm-notifications.

Riverpod · Firebase Messaging · flutter_local_notifications · Medusa /social/fcm-notifications

API surface

MethodEndpointPurposeAuthStatus
GET/social/fcm-notifications?limit=50&offset=0Fetch a page of in-app notifications for a specific token (called per role when both customer + seller tokens exist).no status
POST/fcm/tokenRegister the device's FCM token for a given auth scope so the backend can target push to this device + role.no status
POST/social/fcm-notifications/toggle-readFlip the read state of a single notification — fired optimistically after the UI updates.no status

Presentation layer

Notifications Screen · views/notifications_screen.dart

Lists all notifications with type-coded icons and colors, an unread-count chip in the header, a 'Mark all as read' action, and per-row tap to navigate or open detail.

  1. Watches notificationsControllerProvider for state.
  2. Pull-to-refresh calls refresh.
  3. Row tap fires markAsRead and navigates based on type / template (orders → /orders/:id; messages → /messages).
  4. 'Mark all read' calls markAllAsRead.
  • Empty state renders illustration + 'No notifications yet'.
  • Type colors come from the model extension — keep palette in sync with theming.
  • Long-press on a row reveals a dismiss action that calls deleteNotification (local-only).
lib/src/features/shared/notifications/views/notifications_screen.dart
lib/src/features/shared/notifications/views/notification_detail_screen.dart

The unread badge consumers (e.g. home bell icon) should ref.watch the state.unreadCount derived getter, not the full list.

Domain layer

NotificationItem + Type · models/notification_item.dart

Single notification entity: id, title, body, NotificationType enum (order / post / promotion / system / message), template string, createdAt, isRead, payload bag, and the authToken under which it was fetched (so mark-as-read can hit the right token).

  1. NotificationItem.fromJson reads title from data.title or falls back to template string.
  2. _typeFromTemplate maps backend template strings to the local NotificationType enum.
  3. NotificationTypeUI extension provides per-type icon, color, lightColor, darkColor and French label.
  4. NotificationsResponse.fromJson wraps the paginated list with count/limit/offset.
  • Unknown template strings fall back to NotificationType.system (grey, info icon) — defensive against new backend templates.
  • authToken is propagated through copyWith — without it, markAsRead can't tell which role's endpoint to call.
  • createdAt defaults to DateTime.now() when missing — only affects sort order, never delivered server-side.
lib/src/features/shared/notifications/models/notification_item.dart:4 // NotificationType enum
lib/src/features/shared/notifications/models/notification_item.dart:90 // _typeFromTemplate
lib/src/features/shared/notifications/models/notification_item.dart:111 // NotificationItem
lib/src/features/shared/notifications/models/notification_item.dart:180 // NotificationsResponse

The French labels are hard-coded in the extension — should move to ARB if multi-language coverage is added.

Notifications Controller · controllers/notifications_controller.dart · Notifier<NotificationsState>

Notifier that fetches both customer and seller notification streams in parallel, merges them by id, preserves prior read state, sorts newest-first, and exposes markAsRead / markAllAsRead / deleteNotification.

  1. build() schedules Future.microtask(refresh) — initial fetch on first watch.
  2. refresh reads both tokens from AuthStorageService and fetches in parallel via Future.wait.
  3. Per-token fetches use a raw Dio (not DioHelper) so each call carries its own Authorization explicitly.
  4. De-dup by id with putIfAbsent → preserves read flags from previous state → newest-first sort.
  5. markAsRead writes optimistic state, then fires /social/fcm-notifications/toggle-read with the original authToken.
  • If only one role is present, the other future is omitted — the list still works.
  • If a per-token fetch throws, the other is preserved — failures don't cascade.
  • Preserving read status from existing state across refresh is intentional — the server might not yet reflect a just-tapped 'read' state.
  • deleteNotification is local-only — no backend delete endpoint today, so swiping just hides until next refresh.
  • markAllAsRead fires one toggle-read POST per unread notification — could be batched but isn't yet.
lib/src/features/shared/notifications/controllers/notifications_controller.dart:42 // NotificationsController
lib/src/features/shared/notifications/controllers/notifications_controller.dart:51 // refresh (parallel both tokens)
lib/src/features/shared/notifications/controllers/notifications_controller.dart:114 // _fetchNotifications (raw Dio)
lib/src/features/shared/notifications/controllers/notifications_controller.dart:178 // _toggleReadApi

Using a raw Dio bypasses DioHelper's path heuristic — necessary because both /social paths would otherwise resolve to whichever role is 'active' and you'd miss the other token's notifications.

NotificationsState · controllers/notifications_controller.dart · const class

Immutable state holding isLoading, the merged notifications list, and an optional error. Exposes a derived unreadCount used by the bell badge.

  1. const default is NotificationsState(isLoading: true, notifications: const []).
  2. copyWith allows partial updates; error is intentionally pass-through (passing null clears).
  3. unreadCount = notifications.where(!isRead).length — recomputed per watcher.
  • unreadCount is O(n) — fine for ~50 items; if pagination grows, cache it on update.
  • An empty list with a non-null error is a valid 'we tried, failed' state — UI should show retry, not skeleton.
lib/src/features/shared/notifications/controllers/notifications_controller.dart:12 // NotificationsState

Stays in the same file as the controller — split if state grows independent concerns.

Data layer

NotificationService (FCM + Local) · core/services/notification_service.dart

Singleton wrapping FirebaseMessaging + flutter_local_notifications. Handles: permission request, foreground listener (shows a local notification), background handler, token retrieval/refresh callback, terminated-state initial-message check, topic sub/unsub.

  1. initialize() requests permission, registers background handler, subscribes to onMessage, sets up local notifications channel.
  2. Foreground messages with a notification payload are mirrored into a local notification via _showNotification.
  3. FirebaseMessaging.onTokenRefresh forwards to _onTokenRefresh callback registered by DioHelper.syncFcmTokenForAllRoles.
  4. Background handler is a top-level @pragma('vm:entry-point') function — must stay at file scope.
  5. deleteToken is invoked on logout (AuthStateNotifier).
  • Android creates a high-importance channel ('high_importance_channel') — required for heads-up display.
  • iOS requires presentAlert/presentBadge/presentSound to be true at show time, otherwise the local notification is silent.
  • Permission denial silently fails — initialize still completes; getToken may return null. UI shows no notifications without surfacing this to the user yet.
  • Topic subscribe/unsubscribe is exposed but not used today.
lib/src/core/services/notification_service.dart:8 // _firebaseMessagingBackgroundHandler
lib/src/core/services/notification_service.dart:15 // NotificationService
lib/src/core/services/notification_service.dart:33 // initialize
lib/src/core/services/notification_service.dart:121 // _initLocalNotifications
lib/src/core/services/notification_service.dart:180 // deleteToken

Initialize is invoked from main.dart before runApp; flutter_local_notifications must initialize after Firebase to share channel IDs cleanly. Native SDK contracts (no HTTP — Firebase + local notifications plugin): • FirebaseMessaging.requestPermission(alert,badge,sound,…) → NotificationSettings { authorizationStatus } — screen: app boot (main.dart) • FirebaseMessaging.getToken() → String? (device FCM token) — screen: app boot + DioHelper._checkAndSendFcmToken • FirebaseMessaging.onTokenRefresh → Stream<String> — screen: app boot (wires into DioHelper.syncFcmTokenForAllRoles) • FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler) — top-level entry-point — screen: app boot (debug log only) • FirebaseMessaging.onMessage.listen → RemoteMessage { notification: { title, body }, data: {…} } — screen: app boot → _showNotification (mirrors to local notif) • FirebaseMessaging.onMessageOpenedApp / getInitialMessage() → RemoteMessage — screen: app boot (deep-link to /orders/:id, etc.) • flutter_local_notifications.show(id, title, body, NotificationDetails(...)) → void — channel 'high_importance_channel'.

FCM Token Sync (per-role) · core/network/dio_helper.dart · syncFcmTokenForAllRoles + _checkAndSendFcmToken

Backend cares which (auth_token, fcm_token) pairs are active — the customer and seller tokens get separate FCM registrations. DioHelper owns this side channel, keyed by the tuple in SharedPreferences so a re-send only fires on actual change.

  1. First-of-session: on the first authenticated request, _checkAndSendFcmToken fires fire-and-forget for that auth token.
  2. syncFcmTokenForAllRoles is explicitly called after login to register both tokens if dual login succeeded.
  3. _checkAndSendFcmToken uses a separate raw Dio (not the singleton) to avoid interceptor recursion.
  4. AuthStorageService.isFcmTokenSent / markFcmTokenSent dedupe by (fcmToken, last20charsOfAuthToken).
  • _fcmSyncInProgress Set prevents two parallel requests with the same auth token from each sending the FCM token (burst-at-startup race).
  • Empty fcmToken is a hard 'skip' — happens before FirebaseMessaging permission resolves.
  • Token-tracking keys are never cleared on logout — fine, because clearSession wipes the tokens themselves and the keys become orphaned but harmless.
lib/src/core/network/dio_helper.dart:64 // syncFcmTokenForAllRoles
lib/src/core/network/dio_helper.dart:82 // _checkAndSendFcmToken
lib/src/core/services/auth_storage.dart:219 // _getFcmTrackingKey

When debugging missing pushes, first verify the right key in SharedPreferences exists ('fcm_sent_<token>_<suffix>') — if missing, the upload never fired.

Mark-as-Read Pipeline · _toggleReadApi · POST /social/fcm-notifications/toggle-read

Read receipts are optimistic: the UI flips isRead immediately, then a raw Dio POST hits the toggle-read endpoint with the SAME token the notification was fetched under. Failure is logged but not surfaced.

  1. markAsRead → optimistic state update + _toggleReadApi(id, authToken).
  2. Raw Dio POST /social/fcm-notifications/toggle-read with explicit Bearer header.
  3. On success: debug log only; on failure: debug log only — UI does not roll back.
  4. Read state is preserved across refresh by the controller (see notifications-controller).
  • If the user goes offline and marks notifications read, the server stays out of sync until the next online session; a future enhancement could queue these toggles.
  • Using the per-notification authToken (and not the current role's token) is critical — cross-role markRead would silently 404.
lib/src/features/shared/notifications/controllers/notifications_controller.dart:148 // markAsRead
lib/src/features/shared/notifications/controllers/notifications_controller.dart:178 // _toggleReadApi

Backend exposes the toggle as idempotent — calling it twice on an already-read notification is safe.

Dependency Injection layer

Notifications Route + FCM Bootstrap · core/routing/app_router.dart · /notifications + main.dart init

GoRoute for /notifications inside the ShellRoute with a custom slide-from-bottom + fade transition. NotificationService.initialize is called from main.dart before runApp so messaging is live for the first frame.

  1. main.dart: await NotificationService().initialize() → registers handlers, asks permission, gets initial token.
  2. main.dart wires FirebaseMessaging.onTokenRefresh → DioHelper().syncFcmTokenForAllRoles().
  3. /notifications route in ShellRoute uses CustomTransitionPage with a 400ms slide-and-fade.
  • If initialize fails (no Google Play Services on Android, permission denied on iOS), the rest of the app still boots — handlers are no-ops.
  • Top-level background handler must remain top-level (Dart isolate constraint) — don't move it inside the class.
lib/main.dart // Firebase.initializeApp + NotificationService.initialize
lib/src/core/routing/app_router.dart:667 // /notifications GoRoute

Background handler runs in a separate isolate without your Riverpod tree — it can only log; UI updates must wait for onMessageOpenedApp.

Notifications Provider · notificationsControllerProvider · NotifierProvider

Single NotifierProvider exposes the merged in-app stream. NotificationService is a process-wide singleton with no provider wrapper — it predates Riverpod adoption in this codebase.

  1. NotificationsScreen ref.watches the provider.
  2. Home bell badge ref.watches state.unreadCount.
  3. Provider survives screen pops (no autoDispose) so badge stays accurate.
  • Re-mounting NotificationsScreen does not re-fetch — refresh must be called explicitly (or pull-to-refresh).
lib/src/features/shared/notifications/controllers/notifications_controller.dart:215 // notificationsControllerProvider

Wrapping NotificationService in a Provider would simplify testing — currently any code that constructs NotificationService() gets the singleton.

Profile & Profile Setup

Dual-role profile surface: customer info (/store/customers/me) and seller info (/vendor/sellers/me) fetched in parallel, plus a multi-page profile-setup wizard run after registration and a manage-profile editor for in-session edits.

Riverpod · Medusa /store/customers/me · Vendor /vendor/sellers/me

API surface

MethodEndpointPurposeAuthStatus
GET/store/customers/meFetch the authenticated customer's full profile (identity + addresses + metadata).no status
POST/store/customers/meUpdate the authenticated customer's profile (changed fields only).no status
GET/store/customers/me/personal-infoFetch a lighter profile slice used by header widgets (display name, avatar, role badges).no status

Presentation layer

Profile Tab + Detail Screens · profile/profile_tab_page.dart · profile.dart · profile_details.dart · login_required_profile.dart

Four screens forming the profile surface. ProfileTabPage is the bottom-nav tab (or LoginRequiredProfile when guest). ProfilePage is the in-shell menu screen. ProfileDetailsPage renders the full info card. SeavenProfileHeader and InstagramProfileHeader pick the layout per role.

  1. ProfileTabPage decides login vs not-login based on authStateProvider.
  2. LoginRequiredProfile shows the login/register CTAs for guests.
  3. ProfilePage shows the menu of actions (manage profile, settings, become seller, devenir prestataire, logout).
  4. ProfileDetailsPage watches personalInfoProvider and renders the cards (info, addresses, etc.).
  5. BecomeSellerBanner appears for clients only — links to /devenir-prestataire.
  • MetiersBottomSheet (seller-only) lets a seller pick or edit their métiers; consumed by currentSellerTradesProvider.
  • Avatar editing happens elsewhere (ManageProfileScreen) — the headers are read-only.
  • Pull-to-refresh on the tab triggers personalInfoNotifier.refreshPersonalInfo.
lib/src/features/shared/profile/profile_tab_page.dart
lib/src/features/shared/profile/profile.dart
lib/src/features/shared/profile/profile_details.dart
lib/src/features/shared/profile/login_required_profile.dart
lib/src/features/shared/profile/widgets/seaven_profile_header.dart
lib/src/features/shared/profile/widgets/instagram_profile_header.dart

Two header variants (Seaven and Instagram) are an A/B-style holdover from a redesign sprint — only one is currently shown in production.

Manage Profile Screen · manage-profile/manage_profile.dart

Form-based editor for the authenticated customer profile. Fields for first/last name, phone, photo and addresses. Submits via customerProfileServiceProvider.updatePersonInfo.

  1. Reads current data from customerProfileServiceProvider.
  2. User edits fields; submit builds a CustomerProfileRequest with only the diff.
  3. updatePersonInfo throws → SnackBar with message; succeeds → pop with success snackbar.
  • Avatar upload is handled separately (multipart) and only persists if the user also taps Save afterwards.
  • Phone field is optional — empty string is dropped from the request so backend doesn't null it out.
  • Form is unmounted aggressively on submit success; ref.mounted guard in the notifier protects against this.
lib/src/features/shared/manage-profile/manage_profile.dart
lib/src/features/shared/manage-profile/customer_state.dart

Updating seller-specific fields (métiers, working hours) is in a different screen, not here.

Profile Setup Wizard · profile_setup/presentation/profile_setup.dart + pages/*

Multi-page legacy wizard: role selection → personal info → client OR prestataire-specific → submit. The freelancer path collects diploma, specialties, mobility, working days/hours.

  1. ProfileSetupScreen renders the page indicator + the active page widget.
  2. Pages: role_page → info_page → client_page (clients) | prestataire_page (sellers).
  3. Each page calls ProfileSetupController.next* / set* methods.
  4. Final submit dispatches to /store/customers/me + diploma upload + AuthStorage.markProfileSetupComplete.
  • Indicator widget is hard-coded to N pages — change with care.
  • Hot reload during the wizard wipes the StateNotifier state — design choice, dev-only annoyance.
  • Diploma upload uses path_provider + multipart; failure pops a snackbar but does NOT rollback the customer-profile update.
lib/src/features/shared/profile_setup/presentation/profile_setup.dart
lib/src/features/shared/profile_setup/presentation/pages/role_page.dart
lib/src/features/shared/profile_setup/presentation/pages/info_page.dart
lib/src/features/shared/profile_setup/presentation/pages/client_page.dart
lib/src/features/shared/profile_setup/presentation/pages/prestataire_page.dart
lib/src/features/shared/profile_setup/presentation/widgets/diploma.dart

The redesigned onboarding lives in onboarding_register/ and onboarding_register_seller/ — this wizard is a fallback for users from before the redesign.

Domain layer

Customer Profile Model · manage-profile/customer_profile_response.dart + request.dart

CustomerProfileResponse mirrors /store/customers/me — id, first_name, last_name, email, phone, addresses, metadata. CustomerProfileRequest is the partial update payload posted by the manage-profile screen.

  1. fromJson is tolerant to missing fields — every field optional except id.
  2. Edit screens construct a CustomerProfileRequest with only the changed fields.
  3. Response is consumed by ProfileDetailsPage, header widgets, and the address picker for cart shipping defaults.
  • Metadata is a free-form map — used for client-side flags like 'has uploaded avatar this session'.
  • Phone is optional — backend rejects updates that empty out a previously-set phone, so request omits the field entirely.
lib/src/features/shared/manage-profile/customer_profile_response.dart
lib/src/features/shared/manage-profile/customer_profile_request.dart
lib/src/features/shared/manage-profile/cutomser_details_model.dart

The cutomser_details_model.dart filename is a typo — kept as-is to avoid breaking imports.

Personal Info Model · profile/personal_info_response.dart

PersonalInfoResponse mirrors /store/customers/me/personal-info — the lighter slice used by the profile tab header (display name, photo, role-specific badges).

  1. Returned by UserService.getPersonalInfo.
  2. Cached by PersonalInfoNotifier; consumed by SeavenProfileHeader and InstagramProfileHeader.
  • Photo URL can be null — header falls back to initials.
  • This endpoint exists alongside /store/customers/me because the personal-info shape changes more often than the full profile and the backend wanted a stable slim contract.
lib/src/features/shared/profile/personal_info_response.dart

Don't conflate with CustomerProfileResponse — they overlap but the personal-info endpoint is meant for read-mostly headers, not for edits.

UserProfileIds Provider · personal_info_provider.dart · userProfileIdsProvider + currentCustomerIdProvider

FutureProvider that fetches the authenticated user's customer ID (always) and seller ID (only when role==freelancer) in parallel and returns both. currentCustomerIdProvider selects the active one based on role — consumed by the cart, wishlist, and post-author scoping.

  1. Watches authStateProvider; bails to empty when !isLoggedIn.
  2. Spawns UserService.getClientInfo + (if freelancer) VendorService.getSellerProfile in Future.wait.
  3. currentCustomerIdProvider re-reads userProfileIdsProvider + role and returns sellerId or customerId.
  4. sellerProfileRawProvider exposes the full raw seller payload (used by trades/metiers UI).
  5. currentSellerTradesProvider derives the métier list from the raw seller via parseSellerTrades.
  • Clients NEVER hit /vendor/sellers/me — the role gate exists specifically to suppress 2 wasted 401 requests per home boot.
  • Both fetches swallow their own errors so a failure in one branch doesn't blank the other.
  • AuthState equality override (see auth feature) prevents userProfileIdsProvider from refetching on every login-flag tick.
lib/src/features/shared/profile/personal_info_provider.dart:10 // UserProfileIds
lib/src/features/shared/profile/personal_info_provider.dart:61 // currentCustomerIdProvider
lib/src/features/shared/profile/personal_info_provider.dart:85 // userProfileIdsProvider
lib/src/features/shared/profile/personal_info_provider.dart:124 // sellerProfileRawProvider

Several other features (cart, wishlist) take a hard dependency on currentCustomerIdProvider — keep its semantics stable.

CustomerProfileNotifier · manage-profile/customer_profile_provider.dart · AsyncNotifier

AutoDispose AsyncNotifier for the edit/view screens. loadClientInfo on build, refreshClientInfo for pull-to-refresh, updatePersonInfo for edits — all gated by ref.mounted checks to avoid setting state after disposal.

  1. build() returns getClientInfo() — set loading → data | error.
  2. updatePersonInfo: read userServiceProvider BEFORE setting loading (prevents disposed-ref crash), then POST, then state.data on success.
  3. Errors rethrow so the calling form can show a SnackBar with the message.
  • ref.mounted guard prevents the classic 'setState after dispose' crash when the user navigates away mid-update.
  • AutoDispose means stale state goes away when ManageProfileScreen pops — re-entry refetches.
  • Calling refreshClientInfo immediately after a successful update is redundant — the update response already contains the fresh data.
lib/src/features/shared/manage-profile/customer_profile_provider.dart:7 // CustomerProfileNotifier
lib/src/features/shared/manage-profile/customer_profile_provider.dart:37 // updatePersonInfo (ref.mounted guard)
lib/src/features/shared/manage-profile/customer_profile_provider.dart:70 // customerProfileServiceProvider (autoDispose)

userServiceProvider is declared twice (here and in personal_info_provider.dart) — both yield the same UserService instance, no problem in practice.

PersonalInfoNotifier · profile/personal_info_provider.dart · AsyncNotifier

AsyncNotifier wrapping /store/customers/me/personal-info. Loaded once on first read; refreshPersonalInfo re-fetches. Consumed by header widgets that need just the name/photo slice.

  1. build() awaits loadPersonalInfo.
  2. loadPersonalInfo sets AsyncValue.loading then calls UserService.getPersonalInfo.
  3. refreshPersonalInfo is a thin wrapper used by pull-to-refresh on the profile tab.
  • Errors are captured as AsyncValue.error and returned as null — the screen renders shimmer + retry state.
  • Not autoDispose — the cached personal info is reused across the whole app lifetime.
lib/src/features/shared/profile/personal_info_provider.dart:19 // PersonalInfoNotifier
lib/src/features/shared/profile/personal_info_provider.dart:54 // personalInfoProvider

Both this and CustomerProfileNotifier exist intentionally — different cache lifetimes for different consumers.

ProfileSetupController · profile_setup/presentation/providers/controller.dart

Legacy StateNotifier driving the post-registration profile setup wizard. Holds role choice, personal info, address, freelancer-only fields (diploma, specialties, mobility, working hours), pageIndex, and isLoading/error.

  1. Page-level widgets call setFirstName / setSex / setAddress / setSpecialties etc.
  2. nextPage / previousPage walk through the pages in order (role → info → client/prestataire → confirm).
  3. On final submit, the controller posts the assembled state to /store/customers/me + uploads diploma file (seller path).
  4. On success: mark profile_setup_complete in AuthStorage, refresh authState, navigate to /home.
  • Diploma file upload is gated by role==freelancer — clients skip the field entirely.
  • Page index increments are unbounded — the controller assumes the calling screen knows the page count.
  • isLoading flag is a single boolean — concurrent operations clobber each other (e.g., file upload + form submit).
lib/src/features/shared/profile_setup/presentation/providers/controller.dart:12 // ProfileSetupController
lib/src/features/shared/profile_setup/presentation/providers/state.dart
lib/src/features/shared/profile_setup/presentation/providers/provider.dart

This wizard predates the newer onboarding_register / onboarding_register_seller wizards (see app_router.dart). It is still routed via /profile-setup but no longer the default landing for new users.

Data layer

UserService · core/services/profile_service.dart

Dio-backed client for the three customer-profile endpoints: GET /store/customers/me, POST /store/customers/me (update), GET /store/customers/me/personal-info.

  1. getClientInfo → CustomerProfileResponse.fromJson.
  2. updateCustomerProfile → POST with the changed-fields-only request body.
  3. getPersonalInfo → PersonalInfoResponse.fromJson.
  • Every call carries authScope='customer' RequestTrace — Dio interceptor attaches the customer token even when the user is in freelancer mode.
  • 401 from /store/customers/me is rethrown as 'Unauthorized' — the redirect guard will then bounce to /login.
  • Error responses with HTML bodies are surfaced as 'Failed to fetch …' rather than the raw HTML.
lib/src/core/services/profile_service.dart:9 // UserService
lib/src/core/services/profile_service.dart:13 // updateCustomerProfile
lib/src/core/services/profile_service.dart:49 // getClientInfo
lib/src/core/services/profile_service.dart:114 // getPersonalInfo

Despite being named UserService, the class only knows the customer endpoints — seller profile reads live in VendorService (see seller features). GET /vendor/sellers/me is consumed by userProfileIdsProvider when role=freelancer.

Dependency Injection layer

Profile Routes + Providers · core/routing/app_router.dart · /profile-menu, /profile, /manage-profile, /profile-setup

Four GoRoutes: /profile-menu (ProfilePage menu), /profile (ProfileDetailsPage), /manage-profile (editor), /profile-setup (legacy wizard, OUTSIDE the ShellRoute for full-screen wizard UX).

  1. Auth redirect (see auth feature) bounces incomplete-profile users to /profile-setup or /onboarding_register based on isPendingSellerRegistration.
  2. Profile menu pushes to /manage-profile or /settings.
  3. /profile-setup is outside the ShellRoute → no nav bar.
  • Direct deep-link to /profile-setup with profile already complete bounces back to /home via the auth guard.
  • /profile-menu is the route name 'profileMenu' even though the URL is /profile-menu — keep the name in sync with go_router consumers.
lib/src/core/routing/app_router.dart:326 // /profile-setup (outside ShellRoute)
lib/src/core/routing/app_router.dart:354 // /profile-menu
lib/src/core/routing/app_router.dart:358 // /profile
lib/src/core/routing/app_router.dart:384 // /manage-profile

If you migrate the legacy wizard out, also update the redirect's pendingOnboardingPath fallback in app_router.dart.

Search (Sellers + Places)

Seller-handle search backed by a single lazy fetch + client-side filter, with a persistent local recent-history strip. The same Geoapify autocomplete helper also powers the address pickers in checkout and seller onboarding.

Riverpod · Medusa /store/seller · Geoapify autocomplete · SharedPreferences history

API surface

MethodEndpointPurposeAuthStatus
GET/store/seller?offset=:offset&limit=:limitPaginated list of sellers for the seller-handle search screen.no status
GET/store/seller/:handleFetch a single seller's public detail page (profile + products + prestations + reviews).no status
GEThttps://api.geoapify.com/v1/geocode/autocompletePlace autocomplete for address fields across search, map picker, checkout, prestation.no status
GEThttps://api.geoapify.com/v1/geocode/searchForward-geocode a single query string to a place.no status
GEThttps://api.geoapify.com/v1/geocode/reverseReverse-geocode a (lat, lon) pair to a place suggestion (map picker, prestation).no status

Presentation layer

Search Screen · features/shared/search/search.dart

Full-screen search UI: text field at top, recent-history chips, seller results grid below, pull-to-refresh, near-bottom load-more. Pushed from Home with a slide-from-bottom transition.

  1. On open, ref.watches sellersProvider and renders history strip + empty search state.
  2. Each keystroke calls sellersNotifier.search(query) — local filter is instant; first-time fetch is awaited.
  3. Result tap calls addToHistory(seller) and context.push(/seller/:handle).
  4. Long-press on history chip → removeFromHistory.
  5. Clear-all button on history strip calls clearHistory.
  • Auto-focus is intentional; users land in the search field immediately on screen open.
  • Empty-query + empty history renders an illustration 'No recent searches'.
  • Loading-more spinner is footer-anchored, not full-screen, so existing rows stay visible.
lib/src/features/shared/search/search.dart
lib/src/features/shared/search/widgets/seller_card.dart

Page is wrapped in a PageStorageKey('search_page') so scroll position survives the back/forward push.

Seller Detail Screen · screens/seller_detail_screen.dart

Full seller profile: avatar, métiers, follower count, products grid, prestations, reviews. Custom slide-from-right page transition reinforces the 'deeper into the catalog' navigation.

  1. Reads handle from the GoRouter path param.
  2. ref.watches sellerDetailProvider(handle) — renders SellerDetailShimmer while loading.
  3. Follow / unfollow CTA dispatches into the follow_service (not in this feature) and invalidates the detail provider.
  4. Tapping a product/prestation pushes the appropriate detail route.
  • Deep-link from a notification or shared URL works because the screen reads pathParameters['handle'] and refetches on cold start.
  • 404 errors render an empty state with a 'Back to search' CTA.
lib/src/features/shared/search/screens/seller_detail_screen.dart
lib/src/features/shared/search/widgets/seller_detail_shimmer.dart

Avatar tap doesn't open a full-screen photo viewer today — design decision, not bug.

Domain layer

Seller Model · models/sellers_response.dart · Seller, SellerTrade

Public seller card data — id, name, handle (username), photo, store_status, plus a list of SellerTrade (métier) entries. SellersResponse wraps a paginated list with count.

  1. Seller.fromJson mirrors the /store/seller list item.
  2. SellerTrade is the métier sub-entity; parseSellerTrades extracts it from the seller raw map.
  3. fromJson tolerates missing photo / handle — empty strings default in.
  • store_status is a free-form string ('active', 'paused', 'pending') — UI maps to a color but defaults to grey for unknown values.
  • handle is the only identifier used in URLs (/seller/:handle) — must be unique server-side; the client doesn't validate.
lib/src/features/shared/search/models/sellers_response.dart

SellerTrade is re-exported and consumed by the profile feature (currentSellerTradesProvider) — keep the schema stable.

SellersState · providers/sellers_provider.dart

All-in-one state holding the full fetched sellers list, the filtered subset shown for the current query, the local search history, pagination cursors (offset/limit/totalCount/hasMoreData), and isLoading + isLoadingMore + searchQuery + error.

  1. Empty default — actual content arrives via SellersNotifier.fetchSellers + loadHistory.
  2. copyWith re-uses the standard ?? pattern; error is pass-through so passing null clears it.
  3. filteredSellers is recomputed by _applyLocalFilter every time the underlying sellers list or query changes.
  • Empty filteredSellers + non-empty searchQuery = 'no results for query'.
  • Empty filteredSellers + empty searchQuery = 'show recent history' (by design).
  • hasMoreData=false stops both manual loadMore and the fetchAllSellers loop.
lib/src/features/shared/search/providers/sellers_provider.dart:9 // SellersState

Two separate loading flags exist so the loadMore spinner doesn't replace the existing list with a full-screen loader.

SellersNotifier · providers/sellers_provider.dart · Notifier<SellersState>

Owns seller fetching, paginated load, lazy-load-on-first-search, local filter, search history (load/add/remove/clear), and reset on screen close.

  1. build() schedules Future.microtask(loadHistory) so the history strip appears before any fetch.
  2. search(query) updates searchQuery + filteredSellers locally; if the catalog is empty and query is non-empty, kicks off fetchSellers.
  3. fetchSellers POSTs /store/seller with offset/limit; appends to state.sellers on offset>0.
  4. fetchAllSellers loops fetchSellers until hasMoreData is false (used by Search-All-In-Map flow).
  5. Search history is persisted in LocalStorageService — addToHistory / removeFromHistory / clearHistory all reload via loadHistory.
  • Lazy fetch: the catalog is NOT fetched until the first non-empty search — saves a request on opening Search with no intent.
  • Local filter is name+handle case-insensitive contains — no fuzzy matching; an exact handle match is not prioritized.
  • fetchAllSellers has a safety check `if (currentOffset == previousOffset) break` to prevent infinite loops if the server returns 0 items but a non-zero count.
lib/src/features/shared/search/providers/sellers_provider.dart:65 // SellersNotifier
lib/src/features/shared/search/providers/sellers_provider.dart:104 // fetchSellers (paginated)
lib/src/features/shared/search/providers/sellers_provider.dart:174 // search (lazy)
lib/src/features/shared/search/providers/sellers_provider.dart:192 // _applyLocalFilter
lib/src/features/shared/search/providers/sellers_provider.dart:202 // fetchAllSellers

Debounce was removed (see commented `_debounce?.cancel()` in reset) — current implementation relies on the user to slow down keystrokes.

Seller Detail Provider · providers/seller_detail_provider.dart

FutureProvider.family keyed by handle that fetches /store/seller/{handle} (full profile + products + reviews) for the SellerDetailScreen.

  1. ref.watch(sellerDetailProvider(handle)) on the detail screen.
  2. Provider hits /store/seller/{handle} and returns the typed Seller-detail map.
  3. Family entry stays cached unless invalidated — back-and-forth navigation is instant.
  • Handle conflict (two sellers with the same handle) is a backend bug — client falls back to the first match.
  • 404 returns a typed error inside AsyncValue.error which the screen renders as 'Seller not found'.
lib/src/features/shared/search/providers/seller_detail_provider.dart

ref.invalidate(sellerDetailProvider(handle)) after a follow/unfollow to force a re-fetch with the updated followers count.

Data layer

Sellers Data Source · DioHelper().get('/store/seller')

Direct DioHelper calls inside SellersNotifier — there is no dedicated SellersService class. /store/seller paginates by offset/limit and returns a count for pagination math.

  1. DioHelper.get('/store/seller', queryParameters: { offset, limit })
  2. Response → SellersResponse.fromJson → state mutation.
  3. Each call carries authScope='customer'; clients can browse sellers, sellers can too.
  • Backend counts include 'paused' / 'pending' sellers — the UI does not currently filter them out at fetch time.
  • Network errors surface as 'Something went wrong. Try again!' — generic message by design (no enumeration value).
lib/src/features/shared/search/providers/sellers_provider.dart:114 // DioHelper.get

Promoting these calls into a SellersService would let other features reuse them — today only the search screen consumes /store/seller.

Search History (SharedPreferences) · core/services/local_storage.dart · sellerSearchHistory

Persistent recent-search list keyed globally (not per-user). Up to N entries; new entries push the oldest off the end. Used to render the suggestions strip before the user types.

  1. getSellerSearchHistory reads a JSON array.
  2. addSellerToSearchHistory dedupes by id and prepends.
  3. removeSellerFromSearchHistory drops one entry; clearSellerSearchHistory wipes all.
  • History is shared across customer/seller roles on the same device — switching role keeps the same recent list.
  • JSON-decode error resets to empty (corrupt local state recovers silently).
lib/src/core/services/local_storage.dart // getSellerSearchHistory / addSellerToSearchHistory / removeSellerFromSearchHistory / clearSellerSearchHistory

If we move history to a per-user key (like the cart does), make sure to migrate existing entries to avoid surprising users. Local storage contracts (SharedPreferences): • getSellerSearchHistory() — read 'seller_search_history' → List<Map> → List<Seller> — screen: lib/src/features/shared/search/search.dart (history strip) • addSellerToSearchHistory(seller) — dedupe by id + prepend — screen: search.dart (on result tap) • removeSellerFromSearchHistory(id) — screen: search.dart (long-press dismiss) • clearSellerSearchHistory() — screen: search.dart (Clear all)

Geoapify Service · core/services/geoapify_service.dart

Static helper around Geoapify's autocomplete / geocode / reverseGeocode endpoints. Used by the address pickers in checkout, profile, and the seller onboarding wizard. Search uses it for an in-screen 'find a city' affordance.

  1. isConfigured gates every call — falls back to empty results when GEOAPIFY_API_KEY is missing.
  2. autocomplete: text + optional bias (lat/lon) + limit; returns MapPlaceSuggestion[].
  3. geocode: text → single best match.
  4. reverseGeocode: lat/lon → single MapPlaceSuggestion.
  • Empty/missing API key is a configuration error in .env but returns gracefully (no crash) so debug builds without keys still work.
  • Bias parameter format is 'proximity:lon,lat' — Geoapify uses lon,lat (not lat,lon).
  • Default 5s timeout — Geoapify is rarely slower but a flaky network surfaces as an empty suggestion list, not an error.
lib/src/core/services/geoapify_service.dart:5 // GeoapifyService
lib/src/core/services/geoapify_service.dart:20 // autocomplete
lib/src/core/services/geoapify_service.dart:56 // geocode
lib/src/core/services/geoapify_service.dart:80 // reverseGeocode

Static class with a private constructor — intentional; no DI wrapper. Tests must override at the top of the static cache (or wrap into a provider in the future). All endpoints below hit https://api.geoapify.com/v1 (NOT the Naow backend).

Dependency Injection layer

Search Routes + Providers · core/routing/app_router.dart · /search + /seller/:handle

Two routes: /search (CustomTransitionPage with slide-up animation) and /seller/:handle (CustomTransitionPage with slide-right). sellersProvider is a single NotifierProvider; sellerDetailProvider is a family-keyed FutureProvider.

  1. Home swipe-left or search-icon tap pushes /search.
  2. Seller card tap pushes /seller/:handle with the handle as a path parameter.
  3. Both routes live in the ShellRoute, so the nav bar stays visible.
  • Custom transitions use 420ms forward + 300ms reverse — slightly slower forward to make the new content feel deliberate.
  • GoRouter resolves /seller/:handle from external links — make sure the handle matches the URL-safe charset.
lib/src/core/routing/app_router.dart:521 // /search CustomTransitionPage
lib/src/core/routing/app_router.dart:556 // /seller/:handle CustomTransitionPage
lib/src/features/shared/search/providers/sellers_provider.dart:230 // sellersProvider

If the search experience grows to include products + sellers + places in one screen, split the notifier into per-source notifiers before the controller bloats further.

Seller Services (Provisions)

Seller-side management of provision products (service offerings): list, create via 3-step wizard, view detail, edit options + pricing + cancellation policy. The create flow chains 4-6 backend calls (provision → member assignment → location → policy).

Riverpod · Vendor Provisions API · 4-step creation wizard

API surface

MethodEndpointPurposeAuthStatus
GET/vendor/categoriesTop-level seller_services categories for the category picker.no status
GET/vendor/categories/:parentIdSub-categories of a chosen parent category.no status
POST/vendor/filesMultipart upload of the wizard's thumbnail / images before submit.no status
GET/vendor/provisionsList the seller's provisions (services), including proposed + published.no status
GET/vendor/products/:idProvision detail (preload for the service-details screen).no status
POST/vendor/provisionsCreate a new provision (3-step wizard's main submit).no status
POST/vendor/provisions/:idUpdate a provision (partial).no status
POST/vendor/products/:id/options/:optionIdUpdate a product option (title + values).no status
POST/vendor/products/:id/variants/:variantIdUpdate a variant (prices, title, sku).no status
DELETE/vendor/provisions/:idDelete a provision (services screen).no status
GET/vendor/membersList the seller's members for the member-assignment step.no status
POST/vendor/members/:memberId/provisionsBind selected provisions to a member with their working hours, slot duration, exceptions and location.no status
POST/vendor/product-location/:productIdPin a geographic location on the provision (post member-assign).no status
GET/vendor/bookings/policy/:productIdRead the booking policy attached to a provision (deposit %, cancellation window, refund rules).no status
POST/vendor/bookings/policyCreate or update the booking policy for a provision.no status
GET/store/provisionsCustomer view of published provisions for the marketplace home / client grid tile.no status
GET/store/product-locationMap-driven nearby provisions search (lat/lng/radius).no status
GET/store/provisions/:id?fields=*variants.calculated_price,+variants.inventory_quantity,*seller,*reviews,*reviews.customerProvision detail (full payload with seller + reviews + per-variant pricing).no status
GET/store/provisions/:id?fields=metadataMetadata-only slice used by the booking screen.no status
GET/store/provisions/:productId/slotsAvailable booking slots used by the calendar.no status
GET/store/provisions/:productId/policyCustomer view of the provision's booking policy (deposit %, cancellation window, refund rules).no status
GET/store/provisions/:productId/booking-questionsBooking questions the customer must answer before checkout.no status

Presentation layer

Seller Services Screen · services/services.dart

Lists the seller's provisions with status pills, variant counts, thumbnail, and an action menu (edit, delete, duplicate). FAB pushes the add-prestation wizard.

  1. Watches sellerServicesProvider; renders loading shimmer / list / empty state via AsyncValue.when.
  2. Row tap pushes /prestation/:id (PrestationDetailScreen).
  3. Delete tap opens a confirmation dialog → SellerServicesController.deleteService.
  4. FAB → context.push('/add-prestation').
  • Status pills color-mapped: proposed (amber), published (green), rejected (red), unknown (grey).
  • Empty state nudges the user to tap the FAB ('Add your first service').
  • Pull-to-refresh re-invokes fetch().
lib/src/features/seller/services/services.dart

449 lines — would benefit from extracting the row into a widget but that's a refactor, not a behavior change.

Prestation Detail Screen · service_details/service_details.dart

Read-only display of a single provision: title, description, status, options, variant prices, and (when available) the booking policy card. Edit button routes to EditProvisionScreen.

  1. Watches prestationDetailProvider(serviceId) family entry.
  2. Renders options as chip groups; variants as a price table.
  3. Booking policy card: deposit %, max cancellation delay, refund rules table.
  4. Edit button → context.push('/prestation/:id/edit').
  • If bookingPolicy is null the policy card is replaced by a 'Configure cancellation policy' CTA.
  • Long descriptions are truncated with a 'Show more' toggle.
lib/src/features/seller/service_details/service_details.dart

service_details/CLAUDE.md lists schedule/availability as a future addition — track it there.

Edit Provision Screen · service_details/edit_provision_screen.dart

Editable form for title, description, option label, and variant price tiers. Currently uses DioHelper directly for the product update; policy edit lives here too (POST upsertBookingPolicy).

  1. Loads the existing ServiceDetail via prestationDetailProvider.
  2. Form fields preload from state; submit fires a PATCH-style POST to /vendor/products/{id}.
  3. Policy editor (deposit %, delay, rules list) submits via VendorService.upsertBookingPolicy after the product update succeeds.
  4. Falls back from /vendor/provisions/{id} to /vendor/products/{id} on 404 (older API path).
  • Editing options that already have variants is risky — backend may reject if variant.value strings no longer match options.values.
  • Currency change is disabled in edit mode to prevent price-amount drift.
  • Photo upload reuses the same VendorService.uploadPublicFile helper as add-prestation.
lib/src/features/seller/service_details/edit_provision_screen.dart
lib/src/features/seller/service_details/widgets/edit_provision_widgets.dart

Migrating the product update from DioHelper to VendorService is on the TODO list in service_details/CLAUDE.md.

Add Prestation Wizard · add-prestation/add-prestation.dart

3-step wizard (Informations → Options & Tarifs → Disponibilités). 2723 lines of UI orchestrating the most complex create flow in the app. Submits via the add-prestation controller's 4-6-call pipeline.

  1. Step 1: title, description, thumbnail picker (uploadPublicFile), currency, category + sub-category (vendorProductCategoriesProvider + vendorSubCategoriesProvider).
  2. Step 2: dynamic options + variants table with per-variant price.
  3. Step 3: working hours per day, exceptions, slot duration, address (Geoapify autocomplete → reverseGeocode for lat/lng), and the cancellation policy block.
  4. Submit calls the controller's pipeline; success pops back to /services.
  • Thumbnail upload happens immediately on selection — a failed upload keeps the user on step 1 without proceeding.
  • Step 3 location uses GeoapifyService — without an API key, the address picker is text-only and the submit fails server-side.
  • Policy fields are pre-filled with sensible defaults (deposit=20%, delay=72h) so a hurried seller can ship without touching them.
lib/src/features/seller/add-prestation/add-prestation.dart
lib/src/features/seller/add-prestation/thumbnail.dart
lib/src/features/seller/add-prestation/model/add_prestation_model.dart

The CLAUDE.md in this folder is the canonical multi-call create flow — read before changing the submit order.

Domain layer

Service Models · services/services_model.dart + service_details_model.dart + add-prestation/model/*

Three layers of model: SellerService (list-row slice), ServiceDetail (full detail with options/variants/policy/metadata), and the add-prestation form state (CreatePrestationState + ProvisionPriceTier + CancellationRule).

  1. SellerService is constructed from Product (mapped down inside SellerServicesController.fetch).
  2. ServiceDetail bundles options, variants, BookingPolicy and product metadata for the seller-side detail screen.
  3. BookingPolicy (deposit %, maxCancellationDelayHours, rules[]) is loaded alongside the product in PrestationDetailController.
  4. Add-prestation form has its own state with parsed fields (parsedDepositPercentage, apiCancellationRules) for the submit payload.
  • Older provisions may have no policy — getBookingPolicy can return null and ServiceDetail.bookingPolicy stays null.
  • Variants count is exposed at the list level so cards can show '4 prices configured' without hydrating each variant.
  • Member schedule is part of the model spec (see CLAUDE.md) but is not yet wired into ServiceDetail.
lib/src/features/seller/services/services_model.dart // SellerService
lib/src/features/seller/service_details/service_details_model.dart // ServiceDetail / BookingPolicy / CancellationRule
lib/src/features/seller/add-prestation/model/add_prestation_model.dart // CreatePrestationState
lib/src/features/seller/services/model/booking_policy.dart

Two CLAUDE.md files in service_details/ and add-prestation/ document the missing-policy gap that has since been partially filled — keep them up to date as you finish the work.

Seller Services Controller · services/services_provider.dart · StateNotifier.autoDispose

AutoDispose StateNotifier loading the seller's provisions from /vendor/provisions and mapping them down into the lightweight SellerService rows. Owns the deleteService action.

  1. Ctor → fetch() → VendorService.getProvisions → map Product → SellerService.
  2. AsyncValue<List<SellerService>> tracks loading/data/error.
  3. deleteService removes the row optimistically (rebuilds list) after VendorService.deleteProvision succeeds.
  • delete failure surfaces a typed message extracted from data['message'] when possible.
  • AutoDispose means leaving and returning to the screen always refetches — intentional, since seller edits can change items elsewhere.
  • Status filter (proposed/published/rejected) is done on the UI side; backend returns all statuses.
lib/src/features/seller/services/services_provider.dart:7 // sellerServicesProvider
lib/src/features/seller/services/services_provider.dart:13 // SellerServicesController

Uses the legacy StateNotifier API even though the codebase is mostly Notifier — migrate when convenient.

Prestation Detail Controller · service_details/service_details_provider.dart · family

StateNotifier family keyed by serviceId. Fetches /vendor/products/{id}, parses options and variants, then attempts to load the matching booking policy from VendorService.

  1. Constructor invokes fetch() immediately.
  2. fetch() GETs /vendor/products/{id}, parses options/variants, builds ServiceDetail.
  3. Then calls VendorService.getBookingPolicy(serviceId) and merges BookingPolicy if present.
  4. Errors during policy load are silently swallowed — policy is optional.
  • Older provisions without a policy: getBookingPolicy can 404 — caught and treated as 'no policy yet'.
  • Per-key auto-dispose means visiting another service's detail does NOT invalidate the previous family entry — back/forward is instant up to the autoDispose timeout.
  • Parsing helpers (_parseOption, _parseVariant) tolerate missing fields — bad data renders an empty option/variant rather than crashing.
lib/src/features/seller/service_details/service_details_provider.dart:8 // prestationDetailProvider family
lib/src/features/seller/service_details/service_details_provider.dart:13 // PrestationDetailController
lib/src/features/seller/service_details/service_details_provider.dart:24 // DioHelper GET /vendor/products/{id}
lib/src/features/seller/service_details/service_details_provider.dart:49 // VendorService.getBookingPolicy

Currently uses DioHelper directly for the product fetch and VendorService only for the policy — see service_details/CLAUDE.md for the migration plan.

Add Prestation Wizard Controller · add-prestation/provider/add_product_provider.dart

Drives the multi-step wizard: holds the form state, exposes category providers, and runs the 4-6-call submission pipeline (create → assign → location → policy).

  1. vendorProductCategoriesProvider + vendorSubCategoriesProvider load top-level + nested categories on demand.
  2. Form state mutations (title, thumbnail, options, variants, working hours, location, policy).
  3. Submit pipeline: VendorService.createProvision → /vendor/members → assignProvisionsToMember (provision + workingHour + slotDuration + location) → addProductLocation → upsertBookingPolicy.
  4. Each step bubbles errors to a single 'submitError' state slot.
  • If member assignment fails after createProvision, the provision is created but orphaned — backend cleanup is manual today.
  • Policy upsert is the last step; it can run independently after the rest succeeds (idempotent).
  • Geoapify is used to resolve the address into lat/lng before /vendor/product-location.
  • Currency, sub-category, and option values are validated client-side before the submit fires.
lib/src/features/seller/add-prestation/provider/add_product_provider.dart:21 // vendorProductCategoriesProvider
lib/src/features/seller/add-prestation/provider/add_product_provider.dart:44 // vendorSubCategoriesProvider
lib/src/features/seller/add-prestation/CLAUDE.md // canonical create-flow doc

CLAUDE.md in this folder is the source of truth for the multi-call sequence — keep it in lockstep with the controller. After these calls the wizard chains VendorService contracts (createProvision → assignProvisionsToMember → addProductLocation → upsertBookingPolicy) listed on the vendor-service node.

Data layer

VendorService (Provisions Slice) · core/services/vendor_service.dart · getProvisions / createProvision / deleteProvision / upsertBookingPolicy / …

Large data-layer class wrapping every /vendor/* endpoint. The provisions slice consumed by this feature: getProvisions, getProvision, createProvision, updateProvision, updateProductOption, updateProductVariant, deleteProvision, getBookingPolicy, upsertBookingPolicy. Plus member helpers for assignment.

  1. Every method uses DioHelper and tags requests with feature='seller/…' RequestTrace.
  2. createProvision: POST /vendor/provisions; updateProvision/updateOption/updateVariant: PATCH-style POSTs.
  3. deleteProvision: DELETE /vendor/provisions/{id}.
  4. getBookingPolicy: GET /vendor/bookings/policy/{productId}; upsertBookingPolicy: POST /vendor/bookings/policy.
  5. Member helpers (getMembers, assignProvisionsToMember, updateMemberProvision) are how schedules attach to a provision.
  • All paths start with /vendor/ → DioHelper auth interceptor attaches the seller token automatically.
  • upsertBookingPolicy is idempotent — calling it on an existing policy replaces it (server-side merge).
  • deleteProvision may return 409 if a future booking references it — the controller surfaces the message verbatim.
lib/src/core/services/vendor_service.dart:21 // getProvisions
lib/src/core/services/vendor_service.dart:57 // createProvision
lib/src/core/services/vendor_service.dart:140 // deleteProvision
lib/src/core/services/vendor_service.dart:292 // assignProvisionsToMember
lib/src/core/services/vendor_service.dart:624 // upsertBookingPolicy
lib/src/core/services/vendor_service.dart:769 // getBookingPolicy

VendorService is the elephant — 700+ lines covering provisions, products, members, files, calendar. Treat the provisions slice as one logical area, the products slice as another (see seller-products).

ProvisionService (Customer-side) · core/services/provision_service.dart

Customer-facing counterpart that fetches /store/provisions — the public view of the same data the seller manages. Listed here because the seller features must understand how customers will see their published provisions.

  1. getProvisions: paginated / filtered list — analogous to MarketplaceService.getProducts but for service-type.
  2. getProvisionsWithDistance: same shape with lat/lng/radius for /store/product-location.
  3. getProvisionById: single full provision with `*seller,*reviews` field selection.
  4. getProvisionSlots + getProvisionPolicy + getProvisionQuestions: feed the customer's booking flow.
  • Customer-side endpoints require status='published' on the backend — proposed/rejected provisions are invisible.
  • getProvisionQuestions treats 404 as 'no questions configured' (per-provision optional).
  • Mirroring fields: customer sees /policy as deposit/max-delay/rules — same shape the seller upserts via /vendor/bookings/policy.
lib/src/core/services/provision_service.dart:8 // ProvisionService
lib/src/core/services/provision_service.dart:12 // getProvisions
lib/src/core/services/provision_service.dart:204 // getProvisionSlots
lib/src/core/services/provision_service.dart:227 // getProvisionPolicy

Keep ProvisionService and the vendor side aligned — a field added to the create payload usually needs surfacing in both sides.

Dependency Injection layer

Seller Services Routes · core/routing/app_router.dart · /services + /prestation/:id + /add-prestation

Three routes: /services (list, inside ShellRoute), /prestation/:id and /prestation/:id/edit (inside ShellRoute), /add-prestation (OUTSIDE ShellRoute for full-screen wizard).

  1. /services lists provisions for the authenticated seller.
  2. Row tap pushes /prestation/:id with the id as path param.
  3. Edit pushes /prestation/:id/edit; FAB pushes /add-prestation.
  4. Auth redirect gates /add-prestation behind hasSellerRole.
  • /add-prestation is the only seller route declared OUTSIDE the ShellRoute (full-screen wizard).
  • Edit route uses a regex match (/prestation/:id/edit) so the redirect can detect it without route-name awareness.
lib/src/core/routing/app_router.dart:454 // /services
lib/src/core/routing/app_router.dart:507 // /prestation/:id/edit
lib/src/core/routing/app_router.dart:513 // /prestation/:id
lib/src/core/routing/app_router.dart:731 // /add-prestation (outside ShellRoute)

If you ever move /add-prestation back into the ShellRoute, the nav bar overlap on small screens needs UX review.

Seller Products (Physical)

Seller-side physical product CRUD via /vendor/products: list, create with dynamic options/variants matrix, edit, delete. Mirrored on the customer side by ProductDetailScreen which reads /store/physical-products and feeds the local cart.

Riverpod · Vendor Products API · Image upload via /vendor/files

API surface

MethodEndpointPurposeAuthStatus
GET/vendor/products?type=physicalTyped list of the seller's physical products (Product[]).no status
GET/vendor/products?type=physicalRaw-map variant used to skip Product.fromJson when the screen only needs a subset.no status
GET/vendor/products/:idPreload a single product for the edit screen.no status
POST/vendor/productsCreate a new physical product with options + variants + images.no status
POST/vendor/products/:idUpdate product fields (partial).no status
DELETE/vendor/products/:idDelete a physical product.no status
GET/vendor/stock-locationsBootstrap the list of stock locations (warehouse / store).no status
POST/vendor/stock-locationsCreate a first-time stock location.no status
POST/vendor/inventory-items/:id/location-levelsSet initial stocked_quantity for a variant at a stock location.no status
POST/vendor/inventory-items/:id/location-levels/:levelIdUpdate a variant's stocked_quantity at an existing location level.no status
POST/vendor/product-location/:productIdAttach a geographic location (lat/lng/address) to a product.no status
POST/vendor/filesMultipart upload of a product image to the vendor files endpoint.no status
GET/store/physical-products/:productId?fields=*variants.calculated_priceCustomer-side single product fetch (fallback when ProductDetailScreen has no route extra).no status

Presentation layer

Seller Products Screen · products/views/seller_products_screen.dart

List of the seller's physical products with status pills, variant counts, edit + delete actions, FAB to create. Pull-to-refresh re-invokes the controller.

  1. Watches sellerProductsProvider AsyncValue.
  2. Row tap → context.push('/seller/products/:id/edit').
  3. Delete confirm → SellerProductsController.deleteProduct.
  4. FAB → context.push('/add-product').
  • Empty state suggests creating the first product with an illustration.
  • Delete-failure dialog shows the server error verbatim (often 'product has active orders').
lib/src/features/seller/products/views/seller_products_screen.dart

Status color mapping duplicated from the provisions screen — extract into a shared widget once stable.

Create + Edit Product Screens · products/views/create_product_screen.dart · edit_product_screen.dart

Two-step wizards. Create: 975-line composite of basic info → options matrix → variant pricing → image upload. Edit: same form prefilled, plus an inventory-quantity per variant input.

  1. Create: pickCategory → setTitle/desc → addOption × N → fill variant prices → upload images → submit.
  2. Edit: load existing product → modify any field → submit triggers updatePhysicalProduct + setInventoryItemQuantity for changed variants.
  3. Image picker uses image_picker; uploaded URLs are appended to product.images.
  • Editing options after variants exist is risky — the matrix re-builds, but server may refuse if existing variant.value strings no longer fit.
  • Currency change is allowed in edit but rare — UI shows a warning.
  • Long-press on a variant offers a 'duplicate' helper to copy price into adjacent variants.
lib/src/features/seller/products/views/create_product_screen.dart
lib/src/features/seller/products/views/edit_product_screen.dart

Create screen is 975 lines — a good candidate for splitting into step-widgets but not blocking shipping.

Product Details Screen (customer) · product_details/views/product_details_screen.dart

Customer-facing product page: image slider, info section, size/color selectors, quantity stepper, description + specs, sticky bottom Add-to-cart bar. Receives a Product via route extra or refetches via loadProduct.

  1. Reads Product from state.extra; otherwise loadProduct(id).
  2. ProductSizeSelector + dynamic option selectors call selectOption.
  3. Quantity selector calls setQuantity.
  4. ProductBottomBar Add-to-cart → cartControllerProvider.addToCart with selectedVariant.
  5. Favorite toggle dispatches into ProductDetailsController.toggleFavorite (which mirrors into marketplace).
  • selectedVariant=null disables the Add-to-cart button (no SKU resolves the current options).
  • Image slider lazy-loads via Image.network; pre-fetch is not implemented yet.
  • Quantity steppers respect inventory_quantity when present on the variant.
lib/src/features/shared/product_details/views/product_details_screen.dart
lib/src/features/shared/product_details/views/widgets/product_image_slider.dart
lib/src/features/shared/product_details/views/widgets/product_size_selector.dart
lib/src/features/shared/product_details/views/widgets/product_bottom_bar.dart

The customer-side product detail lives in shared/, not in seller/ — adjacent to its sibling provision_details/ which handles bookings.

Domain layer

Seller Product Model · products/models/seller_product_model.dart

SellerProduct is the seller-side card slice: id, title, description, thumbnail, status, variants count, createdAt. Built from the raw /vendor/products map (not the customer Product entity) so seller-only fields (status='proposed') are surfaced.

  1. SellerProduct.fromJson takes the raw map returned by getPhysicalProductsRaw.
  2. Consumed by SellerProductsScreen rows.
  • Status defaults to 'proposed' on creation — admin moderation flips it to 'published'.
  • thumbnail can be empty — UI shows a placeholder.
lib/src/features/seller/products/models/seller_product_model.dart

Distinct from the customer Product model (marketplace/models/product.dart) — keep both, they serve different surfaces.

Create Product Form State · products/providers/create_product_provider.dart · ProductOptionEntry + ProductVariantEntry + CreateProductState

The dynamic options/variants matrix that powers the create wizard. ProductOptionEntry holds title + values (e.g. Size → S,M,L). ProductVariantEntry holds the cross-product (S/Rouge, S/Bleu, …) with per-variant price/sku/stock.

  1. User adds an Option row → ProductOptionEntry.values builds incrementally.
  2. Each new option or value rebuilds the variant matrix (cross-product of all option values).
  3. Per-variant fields (price, sku, stockQuantity) are filled inline in step 2.
  4. isValid getter on ProductOptionEntry gates the Continue button.
  • Adding a third option doubles+triples the variant count quickly — UI throttles input + scrolls horizontally.
  • Variant title is composed as 'value1 / value2 / value3' — separator is hard-coded ' / '.
  • Empty values silently drop out of the matrix; an option with 0 valid values forces an isValid=false.
lib/src/features/seller/products/providers/create_product_provider.dart:17 // ProductOptionEntry
lib/src/features/seller/products/providers/create_product_provider.dart:42 // ProductVariantEntry
lib/src/features/seller/products/providers/create_product_provider.dart:74 // CreateProductState

Mirrors the provision-side wizard but kept separate — physical products may have inventory tracking, provisions do not.

Seller Products Controller · products/providers/seller_products_provider.dart · StateNotifier.autoDispose

AutoDispose StateNotifier that loads the seller's products from /vendor/products and exposes deleteProduct. AsyncValue<List<SellerProduct>> backs the list screen.

  1. Ctor → fetch() → VendorService.getPhysicalProductsRaw → SellerProduct.fromJson × N.
  2. deleteProduct: optimistic removal after VendorService.deletePhysicalProduct succeeds.
  3. AsyncLoading on every fetch — pull-to-refresh works out of the box.
  • deletePhysicalProduct can fail if inventory or active line items reference the product — server message is propagated.
  • AutoDispose forces a refetch every time the screen mounts — by design, since seller stock may change.
lib/src/features/seller/products/providers/seller_products_provider.dart:7 // sellerProductsProvider
lib/src/features/seller/products/providers/seller_products_provider.dart:13 // SellerProductsController

Pagination not implemented yet — relies on the backend default limit. Add cursor support if seller catalogs grow large.

Create Product Controller · products/providers/create_product_provider.dart

Holds the form state and runs the multi-call submit: upload images via VendorService.uploadPublicFile, build the create payload, POST /vendor/products, then attach the inventory rows via setInventoryItemQuantity + assignInventoryItemLocations + addProductLocation.

  1. setTitle / setDescription / setImageFiles / pickCategory mutate state.
  2. addOption / addOptionValue / removeOption rebuild the variants matrix.
  3. submit: uploadPublicFile per image → createPhysicalProduct → assignInventoryItemLocations + setInventoryItemQuantity per variant.
  4. Errors short-circuit and surface in state.submitError.
  • Image upload is sequential — parallelizing would speed up but the backend rate-limits /vendor/files.
  • Variant inventory is set as an after-creation step; if it fails, the product exists but with 0 stock.
  • Currency is per-product (not per-variant) — Medusa supports per-variant but the UI simplifies.
lib/src/features/seller/products/providers/create_product_provider.dart
lib/src/core/services/vendor_service.dart:230 // createPhysicalProduct
lib/src/core/services/vendor_service.dart:403 // assignInventoryItemLocations
lib/src/core/services/vendor_service.dart:423 // setInventoryItemQuantity

Same multi-call pattern as the provision wizard — if you change one, evaluate the other for consistency.

Product Details Controller (customer) · product_details/controllers/product_details_controller.dart

Customer-side controller for ProductDetailScreen. Holds the Product, currently selected options + resolved variant, quantity, isFavorite. _resolveVariant matches the selected options against product.variants and clears the variant when none match.

  1. setFromProduct seeds the state with the Product passed via route extra, auto-selecting the first value of each option.
  2. loadProduct(id) is the fallback path when the user deep-links without an extra — calls MarketplaceService.getProductById.
  3. selectOption mutates selectedOptions and re-resolves the variant.
  4. toggleFavorite mirrors into marketplaceControllerProvider so the parent list stays in sync.
  • If no variant matches the current option combination, selectedVariant is cleared — the Add-to-cart button must disable.
  • Quantity has a floor of 1; UI 'minus' button hides below that.
  • isFavorite is local-only in this controller — the wishlist service is what persists it.
lib/src/features/shared/product_details/controllers/product_details_controller.dart:5 // ProductDetailsState
lib/src/features/shared/product_details/controllers/product_details_controller.dart:50 // ProductDetailsController
lib/src/features/shared/product_details/controllers/product_details_controller.dart:78 // loadProduct
lib/src/features/shared/product_details/controllers/product_details_controller.dart:115 // _resolveVariant

copyWith uses a `clearVariant` bool to explicitly null selectedVariant — copyWith's normal ?? pattern can't express 'set to null' otherwise.

Data layer

VendorService — Physical Products Slice · core/services/vendor_service.dart · physical-products + inventory + files

Subset of VendorService dedicated to physical products: getPhysicalProducts, getPhysicalProductsRaw, getPhysicalProduct, createPhysicalProduct, updatePhysicalProduct, deletePhysicalProduct, plus inventory and file-upload helpers.

  1. getPhysicalProductsRaw returns the raw response list — used by the list controller to skip Product.fromJson.
  2. createPhysicalProduct builds the full product including options, variants and pricing.
  3. uploadPublicFile (multipart) posts to /vendor/files and returns a public URL used as image src.
  4. setInventoryItemQuantity attaches stock to a variant at a stock location.
  • deletePhysicalProduct cascades to variants + inventory items server-side; clients still need to refetch.
  • uploadPublicFile uses a separate Dio config — multipart can't share JSON defaults.
  • Stock locations are per-seller (getStockLocations / createStockLocation) — auto-bootstrapped on first product create.
lib/src/core/services/vendor_service.dart:176 // getPhysicalProducts
lib/src/core/services/vendor_service.dart:230 // createPhysicalProduct
lib/src/core/services/vendor_service.dart:250 // updatePhysicalProduct
lib/src/core/services/vendor_service.dart:271 // deletePhysicalProduct
lib/src/core/services/vendor_service.dart:650 // uploadPublicFile

Same VendorService is shared with seller-services (provisions slice) and seller-balance (Stripe slice). The class has grown to ~770 lines — splitting by domain is on the TODO list.

MarketplaceService.getProductById (customer detail) · core/services/marketplace_service.dart · getProductById

Customer-side single-product fetch used by ProductDetailScreen when it can't rely on the route extra. Returns the typed Product including variants with calculated_price.

  1. loadProduct(productId) calls marketplaceServiceProvider.getProductById.
  2. Response includes variants + calculated_price (per-region pricing).
  3. Product.fromJson maps the response into the typed entity used by the screen.
  • Product not found → throws 'Product not found' which the controller surfaces as state.error.
  • calculated_price requires the `fields=*variants.calculated_price` query — already part of the service method.
lib/src/core/services/marketplace_service.dart:78 // getProductById
lib/src/features/shared/marketplace/models/product.dart // Product

Customer + seller views read different endpoints (/store vs /vendor) and don't share a typed contract — be cautious when refactoring.

Dependency Injection layer

Seller Products + Customer Detail Routes · core/routing/app_router.dart

Four routes touching this feature: /seller/products (list), /add-product (create), /seller/products/:id/edit (edit), /product-details (customer-side, reads extra).

  1. Home grid 'Marketplace' (seller) → /seller/products.
  2. FAB on seller list → /add-product.
  3. Edit on a row → /seller/products/:id/edit.
  4. Customer marketplace tile tap → /product-details with the Product as extra.
  • /seller/* routes are gated by hasSellerRole in the auth redirect; clients trying to deep-link bounce to /home.
  • /product-details has no path id — depends on route extra, so deep-links are not yet shareable.
lib/src/core/routing/app_router.dart:459 // /seller/products
lib/src/core/routing/app_router.dart:464 // /add-product
lib/src/core/routing/app_router.dart:489 // /seller/products/:id/edit
lib/src/core/routing/app_router.dart:499 // /product-details

If you make /product-details a real URL (id in path), update the cart's add-to-cart deep-link and the marketing-share-link generator.

Seller Balance & Payouts

Stripe Connect payout onboarding for sellers: 3-step wizard (personal info → bank → identity verification), plus the balance + transaction list. Talks directly to api.stripe.com for tokenization and file upload, then to the backend to bind the resulting tokens.

Riverpod · Stripe Connect direct API + /vendor/stripe/* · ImagePicker

API surface

MethodEndpointPurposeAuthStatus
GET/vendor/stripe/payout-accountRead the current Stripe Connect payout account state.no status
POST/vendor/stripe/payout-accountSubmit Step 1 of onboarding: account_token from Stripe → backend creates the Connect account.no status
POST/vendor/stripe/bank_accountSubmit Step 2 of onboarding: bank_token from Stripe → backend attaches the bank account.no status
POST/vendor/stripe/verify-identitySubmit Step 3 of onboarding: front-of-document file id → backend submits identity to Stripe.no status
GET/vendor/stripe/accountRefresh the full Stripe account JSON after onboarding completes.no status
GET/vendor/stripe/balanceAvailable + pending balance in cents.no status
GET/vendor/stripe/transactionsPaginated list of seller transactions.no status
GET/vendor/stripe/transactions/:idTransaction detail (rendered in a bottom sheet).no status
POSThttps://api.stripe.com/v1/tokensDirect Stripe call (Step 1): tokenize personal account details into an account token (ct_...).no status
POSThttps://api.stripe.com/v1/tokensDirect Stripe call (Step 2): tokenize bank account details into a bank token (btok_...).no status
POSThttps://files.stripe.com/v1/filesDirect Stripe call (Step 3): upload identity document and receive a file_id.no status

Presentation layer

Balance Screen · balance/views/balance_screen.dart

Header showing available + pending balance, transactions list below, tap-to-open detail bottom sheet. Pull-to-refresh re-invokes loadAll.

  1. Watches balanceControllerProvider.
  2. Header animates the balance count-up on first load.
  3. Each TransactionRow → loadTransactionDetails(id) and shows TransactionDetailSheet.
  4. Empty state when transactions list is empty after loading.
  • Balance widget shows EUR by default; multi-currency carriers would need a tab or currency selector.
  • Sheet reuses showModalBottomSheet; tapping outside dismisses it without resetting selectedTransactionDetail.
lib/src/features/seller/balance/views/balance_screen.dart
lib/src/features/seller/balance/widgets/transaction_detail_sheet.dart

Animated count-up uses a TweenAnimationBuilder so swap doesn't visually 'flicker' on refresh.

Bank Account Wizard Screen · bank_account/views/bank_account_screen.dart

Single screen that switches between three step UIs based on bankAccountControllerProvider.currentStep. Top progress bar reflects completedSteps. Each step has its own validation and submit button.

  1. Step 1 (personal info): name, email, phone, DOB, address → submitPersonalInfo.
  2. Step 2 (bank): account holder, account number, country, currency → submitBankDetails.
  3. Step 3 (identity): pick or take a photo → submitIdentityVerification.
  4. Completed state: shows 'Verified' or 'Pending review' card.
  • submit errors render an inline banner with the _friendlyError message.
  • Identity verification uses image_picker — gallery and camera both 2000×2000 max with quality 85.
  • Validation is per-step; submitting with empty required fields keeps the user on the step.
lib/src/features/seller/bank_account/views/bank_account_screen.dart

714 lines — a candidate for splitting into per-step widget files but works as-is.

Domain layer

Balance + Transaction Models · balance/models/balance_model.dart + transaction_model.dart

BalanceModel mirrors /vendor/stripe/balance (available + pending). TransactionModel mirrors a single row of /vendor/stripe/transactions; TransactionDetailModel is the expanded shape from /vendor/stripe/transactions/:id.

  1. BalanceModel.fromJson normalizes the Stripe payload into typed currency amounts.
  2. TransactionModel.fromJson builds list rows; TransactionDetailModel inflates the per-row detail.
  • Stripe amounts are minor units (cents) — models hold them as int; UI divides by 100 for display.
  • Some transaction types (refunds, fees) carry negative amounts — UI must color-code accordingly.
lib/src/features/seller/balance/models/balance_model.dart
lib/src/features/seller/balance/models/transaction_model.dart

If we add multi-currency sellers, balance arrives as an array per currency — current models flatten to a single 'currency' field.

Payout Account Model · bank_account/models/payout_account_model.dart

Mirrors /vendor/stripe/account. Exposes derived helpers: needsBankAccount, isVerified, isVerificationPending — consumed by the wizard to auto-advance the step.

  1. PayoutAccountModel.fromJson reads capabilities, requirements, charges_enabled, payouts_enabled.
  2. Helpers compute readable booleans the wizard can branch on without reasoning about Stripe internals.
  • Verification can move through pending → verified → pending again if Stripe requests another document.
  • needsBankAccount stays true even after the user adds a card-funded source — only a real bank routing/account number satisfies it.
lib/src/features/seller/bank_account/models/payout_account_model.dart

Helpers exist precisely so the wizard doesn't reach into Stripe-shaped maps; keep them stable as the API evolves.

Balance Controller · balance/controllers/balance_controller.dart · Notifier<BalanceState>

Notifier that loads balance + transactions in parallel and exposes loadTransactionDetails for the detail bottom sheet. clearError / clearDetail bool flags drive copyWith's null overrides.

  1. build() schedules Future.microtask(loadAll); initial state is isLoading: true.
  2. loadAll: Future.wait([getBalance, getTransactions]); maps results into models.
  3. loadTransactionDetails: per-row tap → getTransactionDetails(id) → set selectedTransactionDetail.
  4. refresh re-invokes loadAll.
  • Errors are unwrapped via replaceFirst('Exception: ', '') for a cleaner SnackBar message.
  • If only one of (balance, transactions) succeeds, the other slot stays null — UI handles partial state.
  • loadTransactionDetails clearDetail=true on entry so the bottom sheet can show a loader without flashing stale data.
lib/src/features/seller/balance/controllers/balance_controller.dart:60 // BalanceController
lib/src/features/seller/balance/controllers/balance_controller.dart:73 // loadAll
lib/src/features/seller/balance/controllers/balance_controller.dart:112 // loadTransactionDetails
lib/src/features/seller/balance/controllers/balance_controller.dart:146 // balanceControllerProvider

Owns its StripeService instance directly — not provider-wrapped.

Bank Account Wizard Controller · bank_account/controllers/bank_account_controller.dart · Notifier<BankAccountState>

Drives the 3-step Stripe Connect onboarding. On boot, loads the existing account and computes which step the user lands on. Three submit methods chain Stripe direct calls and backend calls, with mapped error messages for the common Stripe failure modes.

  1. build() → loadPayoutAccount which advances currentStep based on account flags.
  2. submitPersonalInfo: getStripeAccountToken → createPayoutAccount on backend → step → bankDetails.
  3. submitBankDetails: getStripeBankToken → addBankAccount on backend → step → identityVerification.
  4. submitIdentityVerification: uploadFileToStripe → verifyIdentity on backend → refresh account.
  5. _friendlyError remaps known Stripe error substrings into product-friendly messages.
  • Auto-step-advance: an already-verified seller lands directly on completedSteps=3 and the screen shows a 'Done' state.
  • duplicate_error → 'A payment account already exists for this profile.' — Stripe's exact wording is unsuitable.
  • Network / timeout → 'Network connection error.' instead of leaking the underlying exception.
  • Identity verification requires a clear photo — the controller does not pre-validate image quality.
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:72 // BankAccountController
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:85 // loadPayoutAccount (auto step)
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:149 // submitPersonalInfo
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:206 // submitBankDetails
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:275 // submitIdentityVerification
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:326 // _friendlyError

Three completedSteps states matter for the progress bar — the screen reads completedSteps, not currentStep, to decide which checkmarks to draw.

Data layer

StripeService · core/services/stripe_service.dart

Two-Dio service: one (DioHelper) for backend /vendor/stripe/* endpoints, one (_stripeDio) for direct calls to api.stripe.com using the publishable key. Covers payout account get/create, bank token + add, file upload + verify identity, account/balance/transactions.

  1. Backend reads/writes go through DioHelper → auth interceptor attaches the seller token.
  2. Stripe direct calls (tokens, file uploads) use _stripeDio with a Bearer header set to the publishable key.
  3. All methods carry a RequestTrace with feature='seller/stripe' or 'seller/balance'.
  4. Helpers (_assertSuccess) unify the throw path with friendly default messages.
  • getPayoutAccount swallows 404/400/403/401 and returns null — interpreted as 'no account yet'.
  • uploadFileToStripe uses multipart/form-data; bytes are streamed from the chosen file path.
  • getBalance / getTransactions catch every error and return null/empty list — keeps the screen renderable even when partial Stripe outages occur.
  • Direct Stripe calls bypass DioHelper interceptors — no auto-retry, no logging interceptor.
lib/src/core/services/stripe_service.dart:17 // StripeService
lib/src/core/services/stripe_service.dart:43 // getPayoutAccount
lib/src/core/services/stripe_service.dart:77 // getStripeAccountToken
lib/src/core/services/stripe_service.dart:127 // createPayoutAccount
lib/src/core/services/stripe_service.dart:152 // getStripeBankToken
lib/src/core/services/stripe_service.dart:210 // uploadFileToStripe
lib/src/core/services/stripe_service.dart:286 // getBalance
lib/src/core/services/stripe_service.dart:314 // getTransactions

Sharing a single class for direct-Stripe and backend-binding calls is convenient but fragile — separate them if Stripe rotates the publishable key shape.

Dependency Injection layer

Balance + Bank Account Routes · core/routing/app_router.dart · /balance + /bank-account

Two GoRoutes inside the ShellRoute: /balance → BalanceScreen, /bank-account → BankAccountScreen. Both seller-only; the auth redirect bounces clients to /home.

  1. Seller profile menu links to /balance and /bank-account.
  2. If a user without seller role deep-links, redirect kicks them back to /home.
  • /balance is read-only — no destructive actions can happen here.
  • /bank-account is destructive — leaving mid-step preserves state via the Notifier; navigating away from the app entirely re-runs loadPayoutAccount on return.
lib/src/core/routing/app_router.dart:478 // /bank-account
lib/src/core/routing/app_router.dart:484 // /balance

If we extend to a tax-document screen, slot it next to /balance with the same role gate.

Balance / Bank Account Provider Wiring · balanceControllerProvider + bankAccountControllerProvider · NotifierProvider

Two NotifierProviders, neither autoDispose. The bank-account state is intentionally long-lived so the wizard can resume across screen pops; the balance is long-lived so a refresh-on-return is opt-in (pull-to-refresh).

  1. balanceControllerProvider construction triggers Future.microtask(loadAll).
  2. bankAccountControllerProvider construction triggers Future.microtask(loadPayoutAccount).
  3. Both providers expose a refresh() method for pull-to-refresh.
  • Switching to autoDispose for bank-account would lose mid-wizard state on a 'back' tap — currently deliberate.
  • Tests can override either provider via ProviderScope; service is constructed inline so injection requires a setter.
lib/src/features/seller/balance/controllers/balance_controller.dart:146 // balanceControllerProvider
lib/src/features/seller/bank_account/controllers/bank_account_controller.dart:355 // bankAccountControllerProvider

Both controllers construct StripeService directly — not provider-wrapped today.