HOCO B2B
Mobile (Flutter)

App architecture

How both Flutter apps are organised: feature-first layers, Riverpod 3 state, go_router routes for every RA and DA screen, guest mode and status gating, the facet-driven catalog query, errors, configuration, observability and performance rules.

Both apps share one architecture so that code moves freely between them and into the shared packages. The rules below are what reviews check (conventions); the libraries are recommendations, final pick Imad's.

Feature-first layers

Each app is a list of features; each feature has three layers. Shared packages follow the same split.

apps/reseller/lib/src/features/orders/
  data/
    orders_repository.dart        # calls hoco_api, maps DTO → domain, throws AppException
    order_mappers.dart            # extension methods toDomain()
  domain/
    order.dart                    # freezed domain models (Money, OrderStatus, allowed actions)
  presentation/
    orders_screen.dart            # RA-21
    order_detail_screen.dart      # RA-22
    controllers/                  # Riverpod notifiers: one per screen or per command
    widgets/                      # private to the feature
LayerMay importMust notHolds
domain/hoco_core value types onlyFlutter widgets, dio, hoco_apiImmutable models (freezed), enums, pure rules (can this order be cancelled?)
data/hoco_api, hoco_core, hoco_offlineWidgets, BuildContextRepositories, mappers, local sources. Generated DTOs stop here: repositories return domain models, so a contract change touches one mapper, not twenty widgets
presentation/domain/, hoco_ui, hoco_l10n, hoco_cataloghoco_api, dioScreens, widgets, controllers

No use-case classes by default: a repository method per operation is enough. A use case appears only when one action spans several repositories (closing a tour: outbox state + closing preview + close call).

State management — Riverpod 3

Recommendation: Riverpod 3 with code generation (flutter_riverpod, riverpod_annotation, riverpod_generator, optionally riverpod_lint).

OptionForAgainstVerdict
Riverpod 3 + codegenOne mechanism for dependency injection and state; compile-time safe; async caching with AsyncValue; overrides make widget tests trivial; families key state by parameters (an order id, a catalog query)Code generation step; concepts to learn (keepAlive, families, ref.watch versus ref.read)Recommended
Bloc / CubitExplicit events, very testable, popularMore boilerplate per screen; still needs a DI solution (get_it or RepositoryProvider)Fine for large teams; heavier for one developer and two apps
Provider, ChangeNotifierSimpleNo async caching, manual disposal, weak for familiesToo thin
GetX, MobXLittle codeGlobal mutable state, hard to test, opinionated routing (GetX)Avoid
// data — dependency injection is a provider
@Riverpod(keepAlive: true)
OrdersRepository ordersRepository(Ref ref) =>
    OrdersRepository(ref.watch(hocoApiProvider).orders);

// read state — a family keyed by the order id
@riverpod
Future<Order> orderDetail(Ref ref, String orderId) =>
    ref.watch(ordersRepositoryProvider).get(orderId);

// a command — one notifier per action, one idempotency key per logical action
@riverpod
class CancelOrder extends _$CancelOrder {
  final _key = IdempotencyKeyHolder(newKey: uuidV7);

  @override
  FutureOr<void> build(String orderId) {}

  Future<Result<Order>> submit({required String reasonCode}) async {
    state = const AsyncLoading();
    final result = await ref.read(ordersRepositoryProvider).cancel(
          orderId,
          reasonCode: reasonCode,
          idempotencyKey: _key.keyFor(reasonCode),
        );
    if (!ref.mounted) return result;
    switch (result) {
      case Ok(:final value):
        _key.reset();
        ref.invalidate(orderDetailProvider(orderId));
        state = const AsyncData(null);
        return Ok(value);
      case Err(:final error):
        state = AsyncError(error, StackTrace.current);
        return Err(error);
    }
  }
}

// presentation — AsyncValue.when keeps the previous data while refreshing
class OrderDetailScreen extends ConsumerWidget {
  const OrderDetailScreen({required this.orderId, super.key});
  final String orderId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final order = ref.watch(orderDetailProvider(orderId));
    return order.when(
      data: (o) => OrderDetailView(order: o),
      error: (e, _) => ErrorState.from(e, onRetry: () => ref.invalidate(orderDetailProvider(orderId))),
      loading: () => const OrderDetailSkeleton(),
    );
  }
}

Rules:

  • ref.watch in build, ref.read in callbacks, ref.listen for side effects (navigation, snackbars after a command).
  • keepAlive: true only for app-wide state: session and Me, app config, cart badge, unread count, the SSE connection, the sync engine. Everything else auto-disposes.
  • Riverpod 3 retries failed providers automatically. Turn it off globally (ProviderScope(retry: (_, _) => null)): the HTTP layer already retries what is safe to retry (API integration) and screens show an explicit « Réessayer ».
  • Check ref.mounted after every await in a notifier before touching state.

Configuration and dependency injection

Flavors give identity; env/<env>.json gives endpoints (repository). Values are compile-time constants read once:

abstract final class Env {
  static const name = String.fromEnvironment('HOCO_ENV', defaultValue: 'dev');
  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
  static const webBaseUrl = String.fromEnvironment('WEB_BASE_URL');
  static const sentryDsn = String.fromEnvironment('SENTRY_DSN');
  static const mockBaseUrl = String.fromEnvironment('MOCK_BASE_URL');

  static bool get isProd => name == 'prod';

  static void assertValid() {
    if (apiBaseUrl.isEmpty) {
      throw StateError('API_BASE_URL missing — run with --dart-define-from-file=env/<env>.json');
    }
  }
}

Future<void> bootstrap(Widget Function() app) async {
  WidgetsFlutterBinding.ensureInitialized();
  Env.assertValid();
  await Firebase.initializeApp(options: firebaseOptionsFor(Env.name));
  final storage = await HocoStorage.open(); // secure storage: session token, install id
  await SentryFlutter.init(
    (o) {
      o.dsn = Env.sentryDsn;
      o.environment = Env.name;
      o.sendDefaultPii = false;
      o.tracesSampleRate = Env.isProd ? 0.2 : 1.0;
      o.beforeSend = scrubEvent; // drops tokens, phones, coordinates
    },
    appRunner: () => runApp(
      ProviderScope(
        overrides: [hocoStorageProvider.overrideWithValue(storage)],
        retry: (_, _) => null,
        child: app(),
      ),
    ),
  );
}

Providers are the only DI container (no get_it). Anything that touches the outside world is behind a provider so tests can override it: dioProvider, hocoApiProvider, hocoStorageProvider, clockProvider (current time, server offset), uuidProvider, connectivityProvider, locationServiceProvider, databaseProvider (delegate), analyticsProvider.

Recommendation: go_router with StatefulShellRoute.indexedStack for the bottom navigation (each tab keeps its own stack and scroll position), one redirect for every gate, and refreshListenable bound to the app gate below. Routes are plain strings shared with the reseller web, because push payloads carry them (notifications matrix › payload): /orders/:orderId, /returns/:returnId, /support/tickets/:ticketId, /promotions/:promotionId, /finance/invoices/:invoiceId.

Detail routes reachable from several tabs (product, order, promotion, visit) are declared at the top level on the root navigator, so they open over the current tab without switching it. If Akram's design keeps the tab bar on a detail screen, declare that route inside the branch instead.

Reseller app routes

Tabs (reference screens): Accueil / · Catalogue /catalog · Panier /cart (badge) · Commandes /orders · Compte /account. The fixed WhatsApp button floats over the shell on every tab, guests included.

RouteScreenAccessNotes
/splashRA-01PublicInitial location: appConfig.get, then me.get when a token is stored
/welcomeRA-02PublicFirst launch; « Découvrir le catalogue » enters guest mode
/signupRA-03Public?code=AB12CD34 prefilled from an App Link or the install referrer
/signup/verifyRA-04PublicOTP, resend countdown from settings.auth.otpResendSeconds
/signup/storeRA-05PublicStore, wilaya/commune, customer type, password, terms
/pendingRA-06SessionPending or rejected validation; the tabs stay usable
/signinRA-07Public?from= brings the user back to where the guest prompt appeared
/password/forgotRA-08PublicOTP then new password
/RA-09Guest allowedTab 1 Accueil; guest variant without balance tiles and prices
/searchRA-10Guest allowedFull screen over the shell
/scanRA-11Guest allowedCamera; result opens /products/:productId
/catalogRA-12Guest allowedTab 2 Catalogue; the whole filter state is in the query string
sheetRA-13Guest allowedFilters and sort are modal sheets on /catalog, not routes; no price facet for guests
/products/:productIdRA-14Guest allowedPrice lock and « Créer un compte » instead of add-to-cart for guests
/products/:productId/specsRA-15Guest allowed—
/favoritesRA-16SessionHeart icon on a card asks a guest to sign up
/cartRA-17SessionTab 3 Panier; a guest tapping the tab gets the RA-41 sheet, not the route
/checkoutRA-18SessionPENDING_VALIDATION shows the banner and disables « Valider la commande »
/addresses, /addresses/new, /addresses/:addressIdRA-19Session—
/orders/:orderId/placedRA-20SessionStates that the order is Envoyée and waits for HOCO's confirmation
/ordersRA-21SessionTab 4 Commandes; counters and list updated live (SSE)
/orders/:orderIdRA-22SessionLive timeline; cancel, reorder, documents, « Signaler un problème »
/orders/:orderId/confirmRA-23SessionAssisted order awaiting the reseller (push and SSE target)
/promotionsRA-24Guest allowedDaily offers; prices locked for guests
/promotions/:promotionIdRA-25Guest allowed—
/notificationsRA-26SessionInbox; also the fallback for unknown push routes
/accountRA-27Guest allowedTab 5 Compte; guests see sign-up and sign-in calls to action only
/account/storeRA-28SessionCustomer type shown read-only
/financeRA-29Session—
/finance/invoices, /finance/invoices/:invoiceIdRA-30SessionPush target of invoice.*
/finance/payments, /finance/payments/declareRA-31SessionPush target of payment.*
/supportRA-32Guest allowedGuests see the WhatsApp button and FAQ, not tickets
/support/newRA-33Session?type=AFTER_SALES&orderId= from an order opens the after-sales claim form
/support/tickets/:ticketIdRA-34SessionPush and SSE target of ticket.*
/account/settingsRA-35SessionLanguage, notifications, sessions, sign-out
/update, /maintenanceRA-36PublicFull-screen gates; offline, error and empty are in-page states
/orders/:orderId/return, /returns/:returnIdRA-40SessionReturn request on a delivered order within settings.returns.windowDays; tracking
sheetRA-41GuestGuest prompt: price lock, « Créer un compte », « J'ai déjà un compte »
/i/:codeRA-03PublicApp Link entry: stores the code, redirects to /signup (or / when signed in)

V2 routes are reserved, not built: /packs, /packs/:packId (RA-37), /loyalty (RA-38), /loyalty/rewards (RA-39).

Delegate app routes

Tabs: Accueil / · Tournée /tours/today · Prospects /resellers · Rapports /reports · Compte /account. Two of the client's reference screens show this set; the tour-programme reference shows Planning · Carte · Visite · Prospect · Rapport instead — Akram settles it on design day 11 (DES-11), and only the shell widget changes.

RouteScreenAccessNotes
/splashDA-01PublicappConfig.get; with a token, the local database is shown immediately — the network is never awaited
/signinDA-02Publicapp: DELEGATE; a reseller account gets 403 WRONG_APP
/password/forgotDA-03Public—
/permissionsDA-04SessionConsent and permissions; shown until Me.consents.locationTrackingAcceptedAt is set, and again when a permission is revoked
/DA-05SessionTab 1 Accueil
/tours/:tourIdDA-06SessionTab 2 Tournée opens today's tour (/tours/today redirects); list and mini-map share one ordered list
/toursDA-07SessionCalendar and history (online, cached)
/tours/:tourId/mapDA-08SessionFull map, next destination, « Arrivé »
/resellers/:resellerIdDA-09SessionPoint-of-sale sheet
/visits/:visitIdDA-10SessionCheck-in, timer, checklist, note, photos
/visits/:visitId/not-doneDA-11SessionReason sheet
/visits/:visitId/reportDA-12Session—
/resellersDA-13SessionTab 3 Prospects; ?view=map toggles the map
/resellers/newDA-14SessionWorks offline (client-generated id)
/resellers/:resellerId/editDA-15Session—
/invitations/:invitationIdDA-16SessionCreated online from the POS sheet; the cached QR stays viewable offline
/assisted-orders/new, /assisted-orders/:orderIdDA-17Session?resellerId=; online only
/orders, /orders/:orderIdDA-18SessionRead-only orders of my resellers
/tasksDA-19SessionFollow-ups; push target of task.*
/tours/:tourId/closingDA-20SessionNeeds network and an empty outbox for the tour
/reportsDA-21SessionTab 4 Rapports
/objectivesDA-22Session—
/messages, /messages/:announcementIdDA-23SessionAnnouncements and inbox
/accountDA-24SessionTab 5 Compte
/account/zoneDA-25Session—
/account/settingsDA-26SessionLanguage, notifications, location transparency
/syncDA-27SessionSync centre; badge on the account tab when items are pending or in error
/update, /maintenanceDA-28PublicPlus in-page states: offline, GPS off, out of tolerance, permission denied

Guest mode

Guest mode (AUTH-10, RA-41) is a state of the reseller app, not a separate app: no session, no token, the same screens.

AreaGuestSigned in
Home, catalog, search, scanner, product, promotionsOpen; every price is null and rendered by PriceOrLock as a lock with « Créez un compte pour voir les prix »Prices of the reseller's customer type (TTC large, per settings.pricing.priceDisplay)
FiltersAll facets except the price range (the API omits it)Price range included
Add to cart, « Commander maintenant », favoritesButtons visible, tap opens the RA-41 sheet (« Créer un compte », « J'ai déjà un compte »)Normal
Tabs Panier, CommandesTap opens the RA-41 sheet; deep links redirect to /signin?from=…Normal
CompteSign-up and sign-in calls to action, language, legal linksAccount hub
WhatsApp buttonRegion from coarse location (if the guest agrees) or a wilaya picker (location)Me.supportRegion directly
Push, inbox, SSENone (no device registration without a session)Registered after sign-in

Transitions:

  • Guest → signed in (sign-in or sign-up): store the token, load Me, register the device, open the SSE stream, then invalidate every catalog provider and clear the HTTP cache of catalog responses — prices appear and depend on the customer type, so a cached guest response must never be shown to a reseller. Return to from.
  • Signed in → guest (sign-out or 401 on me.get): drop token and Me, close SSE, clear user caches (cart badge, orders, finance), keep the catalog structure cache (no prices in it after the refetch).
  • Customer type changed by the back-office (account.customer_type_changed): refresh Me, invalidate catalog and cart — prices change.

Sign-up, sign-in and status gating

One gate notifier, watched by the router, decides where any route may go:

sealed class Gate { const Gate(); }
final class Booting extends Gate { const Booting(); }            // splash
final class UpdateRequired extends Gate { const UpdateRequired(this.storeUrl); final String? storeUrl; }
final class Maintenance extends Gate { const Maintenance(this.message, this.until); final String? message; final DateTime? until; }
final class Guest extends Gate { const Guest(); }                // reseller app only
final class SignedOut extends Gate { const SignedOut({this.reason}); final String? reason; } // delegate app
final class SignedIn extends Gate { const SignedIn(this.me); final Me me; }

String? resellerRedirect(Gate gate, Uri uri) {
  final path = uri.path;
  return switch (gate) {
    Booting() => path == '/splash' ? null : '/splash',
    UpdateRequired() => path == '/update' ? null : '/update',
    Maintenance() => path == '/maintenance' ? null : '/maintenance',
    Guest() when requiresSession(path) => '/signin?from=${Uri.encodeComponent(uri.toString())}',
    Guest() => path == '/splash' ? '/' : null,
    SignedIn() when isAuthPage(path) || path == '/splash' => '/',
    SignedIn() || SignedOut() => null,
  };
}
ConditionSourceReseller appDelegate app
Version below minimumappConfig.get forceUpdate, or any 426/update (RA-36), blocking/update (DA-28); the outbox is kept and replays after the update
Maintenancemaintenance.enabled, or any 503 MAINTENANCE/maintenance with message and untilSame; offline work continues locally
No token, or 401 on me.getStart-up sequence (me and reference)Guest/signin (DA-02)
403 WRONG_APP at sign-inauth.login with app« Ce compte n'est pas un compte revendeur »« Ce compte n'est pas un compte délégué »
PENDING_VALIDATIONMe.status, Me.reseller.validationStatus/pending after sign-up; tabs usable; prices per settings.catalog.pricesVisibleBeforeValidation; checkout disabled with a banner—
REJECTED validationMe.reseller.validationStatus/pending rejected state with rejectionReason, WhatsApp and support—
SUSPENDED, DISABLED403 ACCOUNT_SUSPENDEDToken dropped, /signin with the explicit message (cahier §6 acceptance)Same, but the outbox is kept and shown in the sync centre after the next sign-in
Commercial suspensionMe.reseller.status == SUSPENDEDOrdering blocked, banner with support contacts; orders and invoices readable—
Password change requiredMe.mustChangePassword or 403 PASSWORD_CHANGE_REQUIREDChange-password screen right after sign-inSame
Location consent missingMe.consents.locationTrackingAcceptedAt == null—/permissions (DA-04) before the home

Sign-up with customer type (AUTH-03, AUTH-11), screens RA-03 → RA-04 → RA-05 → RA-06:

  1. RA-03: phone (E.164 normalised) and the optional invitation code (prefilled from the App Link or the install referrer, displayed AB12-CD34); invitations.resolve shows « Invité par Yacine — Magasin El Wafa ». auth.otp.send with purpose SIGNUP.
  2. RA-04: six-digit code, resend countdown; auth.otp.verify returns the verificationToken and, when the phone belongs to a field prospect, the claimable record (store name, wilaya, the delegate's recorded customer type) used to prefill RA-05.
  3. RA-05: store details, wilaya and commune pickers (reference data cached), business type (settings.signup.businessTypes), customer type as three large choice cards (Super-gros, Demi-gros, Détail, from settings.signup.customerTypes, labels from l10n, one line of help each), password, terms (settings.legal.termsVersion). The screen says the type is checked by HOCO and can only be changed by HOCO afterwards.
  4. auth.signup.reseller returns the token and Me (PENDING_VALIDATION); the app registers the device and lands on /pending, with prices of the declared type visible by default (decision to confirm).
  5. The store profile (RA-28) shows the customer type read-only with « Contactez HOCO pour le modifier »; the API refuses changes with 422 FIELD_NOT_EDITABLE.

Catalog query state

Advanced filters (CAT-12, RA-12, RA-13) are driven by one immutable query and two providers keyed by it: the facets (catalog.facets, counts for the current query) and the product pages (catalog.products.list). The query lives in the URL, so back navigation, deep links and process death restore the same filters.

enum CatalogSort { relevance, popular, newest, priceAsc, priceDesc } // API: relevance | popular | newest | price_asc | price_desc; price sorts signed in only

@freezed
abstract class CatalogQuery with _$CatalogQuery {
  const factory CatalogQuery({
    String? q,
    String? categoryId,                                       // a category or a sub-category
    @Default(<String>{}) Set<String> brandIds,
    int? priceMin,                                            // centimes; ignored for guests
    int? priceMax,
    @Default(<String, Set<String>>{}) Map<String, Set<String>> attributes, // attribute key → values
    @Default(false) bool inStockOnly,
    @Default(false) bool promotionOnly,
    @Default(false) bool newOnly,
    CatalogSort? sort,                                        // null: relevance with q, else popular
  }) = _CatalogQuery;
  const CatalogQuery._();

  factory CatalogQuery.fromUri(Uri uri) => CatalogQueryCodec.decode(uri.queryParametersAll);
  Map<String, List<String>> toQuery() => CatalogQueryCodec.encode(this);
}

@riverpod
Future<CatalogFacets> catalogFacets(Ref ref, CatalogQuery query) async {
  var disposed = false;
  ref.onDispose(() => disposed = true);
  await Future<void>.delayed(const Duration(milliseconds: 300)); // debounce slider and typing
  if (disposed) throw const CancelledException();
  return ref.watch(catalogRepositoryProvider).facets(query);
}

Behaviour:

  • Every change produces a new CatalogQuery and a context.go('/catalog?…'); the facets and the first product page load in parallel. freezed's deep equality makes the query a valid family key.
  • Facet options come only from the facets response: categories and sub-categories, brands, the attributes defined for the selected category (CAT-13 — power in W, connector, cable length, colour, mAh, Bluetooth version), availability, promotion, new arrivals, and the price range bounds when signed in. Nothing is hard-coded, so a new attribute added in the back-office (BO-71) appears without an app release.
  • An option whose count is 0 is shown disabled unless it is selected. The sheet's button reads « Voir 128 produits » from the facets total.
  • Active filters appear as removable chips above the grid with « Tout effacer ». Changing the category clears attribute filters that do not exist in the new category.
  • Availability is a single toggle « En stock uniquement »: stock is never a number (STK-07).
  • The query parameters are those of the catalog API: q, categoryId, brandId, priceMin, priceMax, attr.<key>, availability, promotion, new, sort. CatalogQueryCodec is the only place that knows them; the delegate app adds resellerId (below).

Shared catalog and cart widgets

hoco_catalog holds the catalog repository, CatalogQuery, the product grid and card, search with suggestions, filter and sort sheets, product detail sections, the quantity stepper with the product's limits, cart line tiles, the totals panel and the issue banner. The reseller app and the delegate's assisted order (DA-17) both use them through one port:

/// What the shared widgets fill: the reseller's server cart or a delegate's assisted-order draft.
abstract interface class OrderDraftPort {
  bool get pricesVisible;                                   // false for a guest
  Stream<DraftSnapshot> watch();                            // lines, totals, issues
  Future<Result<DraftSnapshot>> setQuantity(String variantId, int quantity);
  Future<Result<DraftSnapshot>> removeLine(String lineId);
}

// reseller app: cart.get / cart.items.add / cart.items.update / cart.items.remove
final class ServerCartPort implements OrderDraftPort { /* … */ }

// delegate app: delegate.assisted.create / delegate.assisted.update (returns re-priced totals and issues)
final class AssistedDraftPort implements OrderDraftPort { /* … */ }

The stepper sends the final quantity after 400 ms without taps (cart.items.update is an absolute quantity, so repeating it is harmless). Prices in the cart always come from the server response, never from client arithmetic.

In the assisted order the delegate sees the prices of the reseller's customer type: catalog calls made by the delegate app must pass resellerId (scope-checked by the API — catalog), so hoco_catalog's repository takes an optional resellerId that the reseller app never sets. The draft totals returned by delegate.assisted.update remain authoritative.

Errors

Repositories never leak DioException. They throw (for reads) or return in a Result (for commands) one sealed type, so switch is exhaustive:

sealed class AppException implements Exception { const AppException(); }

/// No connection, DNS failure, timeout — the request may not have reached the server.
final class NetworkException extends AppException {
  const NetworkException({required this.timeout});
  final bool timeout;
}

/// The API answered with its error envelope.
final class ApiException extends AppException {
  const ApiException({
    required this.status,
    required this.code,
    required this.message,
    this.details = const {},
    this.requestId,
    this.retryAfter,
  });
  final int status;                    // 400, 401, 403, 404, 409, 422, 426, 429, 500, 503
  final String code;                   // stable, e.g. ORDER_MIN_AMOUNT_NOT_MET
  final String message;                // localised by Accept-Language, safe to show
  final Map<String, Object?> details;  // field errors, limits, conflicting state
  final String? requestId;             // shown in error states, sent to Sentry
  final Duration? retryAfter;
}

/// Anything else (parsing bug, non-JSON 502 from a proxy).
final class UnexpectedException extends AppException {
  const UnexpectedException(this.cause);
  final Object cause;
}

sealed class Result<T> { const Result(); }
final class Ok<T> extends Result<T> { const Ok(this.value); final T value; }
final class Err<T> extends Result<T> { const Err(this.error); final AppException error; }

How each code becomes UX (inline field error, banner, dialog, full screen, silent retry) is the error mapping table. Two defaults keep it small: the server's message is already localised and safe, so unknown business codes show it as is; network and unexpected errors use local strings (« Pas de connexion », « Une erreur est survenue (code req_…) »).

Logging, Sentry and analytics

  • Logging: package:logging with one root listener — console in dev, Sentry breadcrumbs in staging and prod, plus an in-memory ring buffer of the last 500 lines. A hidden diagnostics screen (tap the version seven times in settings) shows environment, version, install id, last sync, outbox counts and the buffer, with « Copier le diagnostic » for pilot support. Redaction is mandatory: no token, no full phone (+213 5•• •• •4 56), no coordinates, no names.
  • Sentry (sentry_flutter, sentry_dio): one project per app; environment from HOCO_ENV; release dz.hoco.reseller@1.4.2+187; debug symbols uploaded from CI for obfuscated builds; sendDefaultPii = false; user = Me.id only; tags requestId (from the error envelope, so Anis finds the server log line), flavor, appId; SentryNavigatorObserver for navigation breadcrumbs; ANR detection on. Ownership and plan: repositories and access.
  • Analytics — Assumption (to confirm with HOCO, it changes the privacy policy and the data-safety form): Firebase Analytics behind an Analytics interface, disabled in dev, no personal data, a short event catalogue: guest_browse, signup_step (step), search, filter_apply, add_to_cart, checkout_submit, order_live_update, whatsapp_open, visit_check_in (within_tolerance), sync_conflict. Business KPIs (adoption, conversion) stay server-side.

Performance rules

Budgets are on quality and testing; these rules keep them reachable on a mid-range Android:

  • const constructors everywhere they apply (the lint enforces it); split big build methods into widgets rather than helper methods.
  • Long lists are lazy: ListView.builder, GridView.builder, SliverList — never shrinkWrap: true around a long list; itemExtent or prototypeItem when rows have a fixed height.
  • Images: cached_network_image with memCacheWidth = displayed width × device pixel ratio; product grids use thumbnailUrl (320 px WebP) and the detail uses url (1600 px). Private files are cached by fileId, not by their signed URL (files).
  • ref.watch(provider.select(…)) when a widget needs one field of a large state (cart badge reads the count, not the cart).
  • Parse large payloads off the UI isolate (Isolate.run for delta pages and catalog pages above a few hundred items); drift runs its queries in a background isolate.
  • Start-up: first frame without waiting for the network — the reseller app shows cached home content, the delegate app the local database; appConfig.get and me.get run with a 3-second budget and fall back to cache.
  • Animations under 300 ms, no blur effects in lists; test on the low-end phone with the performance overlay before merging a new list or grid.

On this page