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| Layer | May import | Must not | Holds |
|---|---|---|---|
domain/ | hoco_core value types only | Flutter widgets, dio, hoco_api | Immutable models (freezed), enums, pure rules (can this order be cancelled?) |
data/ | hoco_api, hoco_core, hoco_offline | Widgets, BuildContext | Repositories, 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_catalog | hoco_api, dio | Screens, 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).
| Option | For | Against | Verdict |
|---|---|---|---|
| Riverpod 3 + codegen | One 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 / Cubit | Explicit events, very testable, popular | More boilerplate per screen; still needs a DI solution (get_it or RepositoryProvider) | Fine for large teams; heavier for one developer and two apps |
Provider, ChangeNotifier | Simple | No async caching, manual disposal, weak for families | Too thin |
| GetX, MobX | Little code | Global 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.watchinbuild,ref.readin callbacks,ref.listenfor side effects (navigation, snackbars after a command).keepAlive: trueonly for app-wide state: session andMe, 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.mountedafter everyawaitin a notifier before touchingstate.
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.
Navigation — go_router
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.
| Route | Screen | Access | Notes |
|---|---|---|---|
/splash | RA-01 | Public | Initial location: appConfig.get, then me.get when a token is stored |
/welcome | RA-02 | Public | First launch; « Découvrir le catalogue » enters guest mode |
/signup | RA-03 | Public | ?code=AB12CD34 prefilled from an App Link or the install referrer |
/signup/verify | RA-04 | Public | OTP, resend countdown from settings.auth.otpResendSeconds |
/signup/store | RA-05 | Public | Store, wilaya/commune, customer type, password, terms |
/pending | RA-06 | Session | Pending or rejected validation; the tabs stay usable |
/signin | RA-07 | Public | ?from= brings the user back to where the guest prompt appeared |
/password/forgot | RA-08 | Public | OTP then new password |
/ | RA-09 | Guest allowed | Tab 1 Accueil; guest variant without balance tiles and prices |
/search | RA-10 | Guest allowed | Full screen over the shell |
/scan | RA-11 | Guest allowed | Camera; result opens /products/:productId |
/catalog | RA-12 | Guest allowed | Tab 2 Catalogue; the whole filter state is in the query string |
| sheet | RA-13 | Guest allowed | Filters and sort are modal sheets on /catalog, not routes; no price facet for guests |
/products/:productId | RA-14 | Guest allowed | Price lock and « Créer un compte » instead of add-to-cart for guests |
/products/:productId/specs | RA-15 | Guest allowed | — |
/favorites | RA-16 | Session | Heart icon on a card asks a guest to sign up |
/cart | RA-17 | Session | Tab 3 Panier; a guest tapping the tab gets the RA-41 sheet, not the route |
/checkout | RA-18 | Session | PENDING_VALIDATION shows the banner and disables « Valider la commande » |
/addresses, /addresses/new, /addresses/:addressId | RA-19 | Session | — |
/orders/:orderId/placed | RA-20 | Session | States that the order is Envoyée and waits for HOCO's confirmation |
/orders | RA-21 | Session | Tab 4 Commandes; counters and list updated live (SSE) |
/orders/:orderId | RA-22 | Session | Live timeline; cancel, reorder, documents, « Signaler un problème » |
/orders/:orderId/confirm | RA-23 | Session | Assisted order awaiting the reseller (push and SSE target) |
/promotions | RA-24 | Guest allowed | Daily offers; prices locked for guests |
/promotions/:promotionId | RA-25 | Guest allowed | — |
/notifications | RA-26 | Session | Inbox; also the fallback for unknown push routes |
/account | RA-27 | Guest allowed | Tab 5 Compte; guests see sign-up and sign-in calls to action only |
/account/store | RA-28 | Session | Customer type shown read-only |
/finance | RA-29 | Session | — |
/finance/invoices, /finance/invoices/:invoiceId | RA-30 | Session | Push target of invoice.* |
/finance/payments, /finance/payments/declare | RA-31 | Session | Push target of payment.* |
/support | RA-32 | Guest allowed | Guests see the WhatsApp button and FAQ, not tickets |
/support/new | RA-33 | Session | ?type=AFTER_SALES&orderId= from an order opens the after-sales claim form |
/support/tickets/:ticketId | RA-34 | Session | Push and SSE target of ticket.* |
/account/settings | RA-35 | Session | Language, notifications, sessions, sign-out |
/update, /maintenance | RA-36 | Public | Full-screen gates; offline, error and empty are in-page states |
/orders/:orderId/return, /returns/:returnId | RA-40 | Session | Return request on a delivered order within settings.returns.windowDays; tracking |
| sheet | RA-41 | Guest | Guest prompt: price lock, « Créer un compte », « J'ai déjà un compte » |
/i/:code | RA-03 | Public | App 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.
| Route | Screen | Access | Notes |
|---|---|---|---|
/splash | DA-01 | Public | appConfig.get; with a token, the local database is shown immediately — the network is never awaited |
/signin | DA-02 | Public | app: DELEGATE; a reseller account gets 403 WRONG_APP |
/password/forgot | DA-03 | Public | — |
/permissions | DA-04 | Session | Consent and permissions; shown until Me.consents.locationTrackingAcceptedAt is set, and again when a permission is revoked |
/ | DA-05 | Session | Tab 1 Accueil |
/tours/:tourId | DA-06 | Session | Tab 2 Tournée opens today's tour (/tours/today redirects); list and mini-map share one ordered list |
/tours | DA-07 | Session | Calendar and history (online, cached) |
/tours/:tourId/map | DA-08 | Session | Full map, next destination, « Arrivé » |
/resellers/:resellerId | DA-09 | Session | Point-of-sale sheet |
/visits/:visitId | DA-10 | Session | Check-in, timer, checklist, note, photos |
/visits/:visitId/not-done | DA-11 | Session | Reason sheet |
/visits/:visitId/report | DA-12 | Session | — |
/resellers | DA-13 | Session | Tab 3 Prospects; ?view=map toggles the map |
/resellers/new | DA-14 | Session | Works offline (client-generated id) |
/resellers/:resellerId/edit | DA-15 | Session | — |
/invitations/:invitationId | DA-16 | Session | Created online from the POS sheet; the cached QR stays viewable offline |
/assisted-orders/new, /assisted-orders/:orderId | DA-17 | Session | ?resellerId=; online only |
/orders, /orders/:orderId | DA-18 | Session | Read-only orders of my resellers |
/tasks | DA-19 | Session | Follow-ups; push target of task.* |
/tours/:tourId/closing | DA-20 | Session | Needs network and an empty outbox for the tour |
/reports | DA-21 | Session | Tab 4 Rapports |
/objectives | DA-22 | Session | — |
/messages, /messages/:announcementId | DA-23 | Session | Announcements and inbox |
/account | DA-24 | Session | Tab 5 Compte |
/account/zone | DA-25 | Session | — |
/account/settings | DA-26 | Session | Language, notifications, location transparency |
/sync | DA-27 | Session | Sync centre; badge on the account tab when items are pending or in error |
/update, /maintenance | DA-28 | Public | Plus 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.
| Area | Guest | Signed in |
|---|---|---|
| Home, catalog, search, scanner, product, promotions | Open; 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) |
| Filters | All facets except the price range (the API omits it) | Price range included |
| Add to cart, « Commander maintenant », favorites | Buttons visible, tap opens the RA-41 sheet (« Créer un compte », « J'ai déjà un compte ») | Normal |
| Tabs Panier, Commandes | Tap opens the RA-41 sheet; deep links redirect to /signin?from=… | Normal |
| Compte | Sign-up and sign-in calls to action, language, legal links | Account hub |
| WhatsApp button | Region from coarse location (if the guest agrees) or a wilaya picker (location) | Me.supportRegion directly |
| Push, inbox, SSE | None (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 tofrom. - Signed in → guest (sign-out or
401onme.get): drop token andMe, 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): refreshMe, 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,
};
}| Condition | Source | Reseller app | Delegate app |
|---|---|---|---|
| Version below minimum | appConfig.get forceUpdate, or any 426 | /update (RA-36), blocking | /update (DA-28); the outbox is kept and replays after the update |
| Maintenance | maintenance.enabled, or any 503 MAINTENANCE | /maintenance with message and until | Same; offline work continues locally |
No token, or 401 on me.get | Start-up sequence (me and reference) | Guest | /signin (DA-02) |
403 WRONG_APP at sign-in | auth.login with app | « Ce compte n'est pas un compte revendeur » | « Ce compte n'est pas un compte délégué » |
PENDING_VALIDATION | Me.status, Me.reseller.validationStatus | /pending after sign-up; tabs usable; prices per settings.catalog.pricesVisibleBeforeValidation; checkout disabled with a banner | — |
REJECTED validation | Me.reseller.validationStatus | /pending rejected state with rejectionReason, WhatsApp and support | — |
SUSPENDED, DISABLED | 403 ACCOUNT_SUSPENDED | Token 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 suspension | Me.reseller.status == SUSPENDED | Ordering blocked, banner with support contacts; orders and invoices readable | — |
| Password change required | Me.mustChangePassword or 403 PASSWORD_CHANGE_REQUIRED | Change-password screen right after sign-in | Same |
| Location consent missing | Me.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:
- RA-03: phone (E.164 normalised) and the optional invitation code (prefilled from the App Link or the install referrer, displayed
AB12-CD34);invitations.resolveshows « Invité par Yacine — Magasin El Wafa ».auth.otp.sendwith purposeSIGNUP. - RA-04: six-digit code, resend countdown;
auth.otp.verifyreturns theverificationTokenand, 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. - 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, fromsettings.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. auth.signup.resellerreturns the token andMe(PENDING_VALIDATION); the app registers the device and lands on/pending, with prices of the declared type visible by default (decision to confirm).- 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
CatalogQueryand acontext.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.CatalogQueryCodecis the only place that knows them; the delegate app addsresellerId(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:loggingwith one root listener — console indev, Sentry breadcrumbs instagingandprod, 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;environmentfromHOCO_ENV; releasedz.hoco.reseller@1.4.2+187; debug symbols uploaded from CI for obfuscated builds;sendDefaultPii = false; user =Me.idonly; tagsrequestId(from the error envelope, so Anis finds the server log line),flavor,appId;SentryNavigatorObserverfor 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
Analyticsinterface, disabled indev, 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:
constconstructors everywhere they apply (the lint enforces it); split bigbuildmethods into widgets rather than helper methods.- Long lists are lazy:
ListView.builder,GridView.builder,SliverList— nevershrinkWrap: truearound a long list;itemExtentorprototypeItemwhen rows have a fixed height. - Images:
cached_network_imagewithmemCacheWidth= displayed width × device pixel ratio; product grids usethumbnailUrl(320 px WebP) and the detail usesurl(1600 px). Private files are cached byfileId, 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.runfor 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.getandme.getrun 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.
Repository and structure
How hoco-mobile is created and laid out: GitHub settings, the Dart pub workspace with Melos scripts, the package tree, application ids, flavors, Firebase projects, signing and the bootstrap commands.
API integration
How the apps consume the contract: pinning the OpenAPI document, generating hoco_api, the Prism mock, the dio interceptors, the SSE client for live order status, pagination, money and date models, uploads, error UX and the contract-change procedure.