HOCO B2B
Mobile (Flutter)

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.

hoco-mobile is the second repository of the project (ADR-001): one Flutter monorepo holding both apps and the shared hoco_* packages. It lives under Imad's GitHub account until it is transferred to an organisation before go-live (repositories and access). This page is the setup of MOB-01.

Create the repository

# Private repository under Imad's account, default branch dev
gh repo create hoco-mobile --private \
  --description "HOCO B2B — reseller and delegate Flutter apps" --clone
cd hoco-mobile
git checkout -b master && git commit --allow-empty -m "chore: initial commit" && git push -u origin master
git checkout -b dev && git push -u origin dev
gh repo edit --default-branch dev --enable-squash-merge --enable-merge-commit \
  --enable-rebase-merge=false --delete-branch-on-merge

# Collaborators (personal repositories give collaborators write access)
gh api -X PUT repos/{owner}/hoco-mobile/collaborators/AnisBetehi
gh api -X PUT repos/{owner}/hoco-mobile/collaborators/<akram-github-handle>

# Labels used by PR rules and triage
for l in contract reseller delegate design-qa pilot hotfix mobile-impact; do gh label create "$l"; done
SettingValueWhy
VisibilityPrivateSigning configuration, environment files and client code
Default branchdevPRs target integration by default, as in hoco
Merge buttonsSquash (default) and merge commit; rebase disabledSquash into dev, merge commits into master (git workflow)
Branch protectionmaster and dev: PR only, CI checks required, no force push; dev needs one code-owner approval except the repository owner's bypass outside sensitive pathsSame rules as hoco — the table is on repositories and access
PlanGitHub Pro on Imad's accountProtection and rulesets on private repositories need Pro, Team or Enterprise; Free gives 2 000 Actions minutes a month, Pro 3 000
SecretsUpload keystore and passwords, Firebase App Distribution and Play service accounts, Sentry auth tokenListed on repositories and access — never in the repository

Branches follow the shared model: master (production) ← dev (integration) ← feature/<TASK-ID>-short-desc (for example feature/MDA-05-visit-flow), fix/<TASK-ID>-…, release/x.y.z, hotfix/x.y.z. .github/CODEOWNERS marks hoco_api, hoco_core, hoco_offline, the platform folders and .github as sensitive paths that wait for Anis's approval (the file is on repositories and access).

Toolchain and SDK constraints

ToolVersion ruleNotes
FlutterLatest stable on day 1, pinned with FVM (.fvmrc committed); upgraded deliberately in a chore(workspace) PR, never mid-featureCI reads the same .fvmrc
DartThe one bundled with the pinned Flutter; environment.sdk: ^3.10.0 in every pubspec.yaml (raise it to the bundled minor)Pub workspaces need Dart 3.6 or later
AndroidminSdk 24 (Android 7.0), compileSdk and targetSdk = latest API level (36 at kickoff)Play requires targeting an API level released within the last year; Android 15+ targets also need 16 KB page-size compatible native libraries (build and release)
JDK17 (Temurin)Required by the Android Gradle plugin
Node22Prism mock server, commitlint
Melos7.x, as a dev dependency of the workspace rootMelos 7 reads its configuration from the root pubspec.yaml
XcodeOnly if iOS is decided (D-10)MOB-16

Workspace and Melos

One flutter pub get at the root resolves every package together (one lockfile, one version of each dependency). Each member declares resolution: workspace.

name: hoco_mobile
publish_to: none

environment:
  sdk: ^3.10.0

workspace:
  - apps/reseller
  - apps/delegate
  - apps/gallery
  - packages/hoco_core
  - packages/hoco_api
  - packages/hoco_ui
  - packages/hoco_l10n
  - packages/hoco_offline
  - packages/hoco_maps
  - packages/hoco_catalog

dev_dependencies:
  melos: ^7.0.0

melos:
  scripts:
    analyze:
      description: Static analysis of the whole workspace; infos fail the build
      run: flutter analyze --fatal-infos
    format:
      description: Format every Dart file
      run: dart format .
    format:check:
      description: CI — fail when a file is not formatted
      run: dart format --output=none --set-exit-if-changed .
    test:
      description: Unit, widget and golden tests of every package that has tests
      run: flutter test --no-pub
      exec:
        concurrency: 2
        failFast: true
      packageFilters:
        dirExists: test
    test:coverage:
      run: flutter test --no-pub --coverage
      exec:
        concurrency: 2
      packageFilters:
        dirExists: test
    gen:
      description: build_runner — freezed, json_serializable, riverpod, retrofit, drift
      run: dart run build_runner build --delete-conflicting-outputs
      exec:
        concurrency: 1
      packageFilters:
        dependsOn: build_runner
    api:gen:
      description: Regenerate hoco_api from the pinned packages/hoco_api/openapi/hoco-v1.json
      run: >-
        melos exec --scope=hoco_api -- dart run swagger_parser &&
        melos exec --scope=hoco_api -- dart run build_runner build --delete-conflicting-outputs
    api:sync:
      description: Download a contract version, pin it and regenerate (see API integration)
      run: bash tool/api_sync.sh
    l10n:
      description: Enum labels from enums.json, then flutter gen-l10n
      run: >-
        dart run tool/gen_enum_l10n.dart &&
        melos exec --scope=hoco_l10n -- flutter gen-l10n
    tokens:
      description: Akram's token JSON to Dart (hoco_ui)
      run: dart run tool/gen_tokens.dart
    mock:
      description: Prism mock server of the pinned contract on port 4010
      run: npx --yes @stoplight/prism-cli@5 mock packages/hoco_api/openapi/hoco-v1.json --host 0.0.0.0 --port 4010

A member package, for example packages/hoco_core/pubspec.yaml:

name: hoco_core
publish_to: none
resolution: workspace

environment:
  sdk: ^3.10.0
  flutter: ">=3.38.0"

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.8.0
  flutter_riverpod: ^3.0.0
  riverpod_annotation: ^3.0.0
  freezed_annotation: ^3.0.0
  json_annotation: ^4.9.0
  flutter_secure_storage: ^9.2.0
  connectivity_plus: ^6.1.0
  package_info_plus: ^8.3.0
  sentry_flutter: ^9.0.0
  sentry_dio: ^9.0.0
  uuid: ^4.5.0
  intl: any            # pinned by flutter_localizations
  logging: ^1.3.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^3.0.0
  json_serializable: ^6.9.0
  riverpod_generator: ^3.0.0
  mocktail: ^1.0.4
  very_good_analysis: ^7.0.0

Version numbers above are floors to check on day 1, not a lockfile; pubspec.lock at the root is the truth and is committed.

Folder tree

CODEOWNERS
PULL_REQUEST_TEMPLATE.md
dependabot.yml — pub and github-actions
main.dart — reads HOCO_ENV, calls bootstrap()
app.dart — MaterialApp.router
bootstrap.dart — Sentry, Firebase, storage, ProviderScope
pubspec.yaml
pubspec.yaml — workspace and Melos scripts
pubspec.lock
analysis_options.yaml
.fvmrc
lefthook.yml
commitlint.config.mjs
.gitattributes — * text=auto eol=lf
README.md — setup, commands, links to these pages

Who may depend on whom — arrows point to the dependency; nothing depends on an app, and hoco_api is only imported by data/ layers:

The reseller app uses hoco_maps only for the coarse location of the WhatsApp region and the optional address pin; it does not embed a map in V1.

Application ids and flavors

Flavors decide identity (application id, launcher name and icon, Firebase project); the env/*.json file passed with --dart-define-from-file decides endpoints (API URL, Sentry environment). That keeps pre-production builds possible without a fourth flavor.

FlavorReseller app idDelegate app idLauncher nameIconFirebase projectEnv files
devdz.hoco.reseller.devdz.hoco.delegate.devHOCO Rev DEV / HOCO Dél DEVAkram's icon with a DEV ribbonhoco-devdev.json (local server), mock.json (Prism)
stagingdz.hoco.reseller.stagingdz.hoco.delegate.stagingHOCO Rev STG / HOCO Dél STGSTG ribbonhoco-stagingstaging.json, preprod.json (acceptance builds)
proddz.hoco.resellerdz.hoco.delegateHOCO Revendeur / HOCO Délégué (Assumption — store names to confirm with HOCO)Final icon (DQA-01)hoco-prodprod.json

The three flavors install side by side on one phone.

{
  "HOCO_ENV": "staging",
  "API_BASE_URL": "https://api-staging.hoco.dz/v1",
  "WEB_BASE_URL": "https://app-staging.hoco.dz",
  "SENTRY_DSN": "https://examplePublicKey@o0.ingest.sentry.io/0",
  "MOCK_BASE_URL": ""
}
Env fileAPI_BASE_URLNotes
mock.jsonhttp://10.0.2.2:4010Prism on the host machine, seen from the Android emulator; on a USB phone run adb reverse tcp:4010 tcp:4010 and use http://127.0.0.1:4010
dev.jsonhttp://10.0.2.2:3100/v1Local server from the hoco docker stack (port 3100)
staging.jsonhttps://api-staging.hoco.dz/v1Auto-deployed from dev of hoco
preprod.jsonAssumption — https://api-preprod.hoco.dz/v1, to confirm when Anis sets up pre-production (OPS-02)Built with the staging flavor for HOCO's acceptance
prod.jsonhttps://api.hoco.dz/v1The hoco.dz domain is a working assumption

Environment files hold no secrets: a Sentry DSN and URLs are public by nature. The Maps API key goes into the Android manifest from Gradle (below).

apps/reseller/android/app/build.gradle.kts (the delegate app is identical with its own ids):

import java.util.Properties

val keystoreProperties = Properties().apply {
    val file = rootProject.file("key.properties")
    if (file.exists()) file.inputStream().use { load(it) }
}
val secrets = Properties().apply {
    val file = rootProject.file("secrets.properties") // MAPS_API_KEY_DEV=…, git-ignored, CI writes it
    if (file.exists()) file.inputStream().use { load(it) }
}

android {
    namespace = "dz.hoco.reseller"
    compileSdk = flutter.compileSdkVersion

    defaultConfig {
        applicationId = "dz.hoco.reseller"
        minSdk = 24
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

    flavorDimensions += "env"
    productFlavors {
        create("dev") {
            dimension = "env"
            applicationIdSuffix = ".dev"
            versionNameSuffix = "-dev"
            resValue("string", "app_name", "HOCO Rev DEV")
            manifestPlaceholders["mapsApiKey"] = secrets.getProperty("MAPS_API_KEY_DEV", "")
            manifestPlaceholders["appLinkHost"] = "app-dev.hoco.dz"
        }
        create("staging") {
            dimension = "env"
            applicationIdSuffix = ".staging"
            versionNameSuffix = "-stg"
            resValue("string", "app_name", "HOCO Rev STG")
            manifestPlaceholders["mapsApiKey"] = secrets.getProperty("MAPS_API_KEY_STAGING", "")
            manifestPlaceholders["appLinkHost"] = "app-staging.hoco.dz"
        }
        create("prod") {
            dimension = "env"
            resValue("string", "app_name", "HOCO Revendeur")
            manifestPlaceholders["mapsApiKey"] = secrets.getProperty("MAPS_API_KEY_PROD", "")
            manifestPlaceholders["appLinkHost"] = "app.hoco.dz"
        }
    }

    signingConfigs {
        create("release") {
            keyAlias = keystoreProperties.getProperty("keyAlias")
            keyPassword = keystoreProperties.getProperty("keyPassword")
            storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
            storePassword = keystoreProperties.getProperty("storePassword")
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
}

The manifest uses android:label="@string/app_name". Per-flavor launcher icons come from flutter_launcher_icons with one config file per flavor (flutter_launcher_icons-dev.yaml, …) generating into src/<flavor>/res.

Cleartext for dev only. Android blocks plain HTTP since API 28. src/dev/res/xml/network_security_config.xml allows it for the emulator host and USB-reversed localhost, and src/dev/AndroidManifest.xml references it — staging and prod keep the default (HTTPS only):

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="false">10.0.2.2</domain>
    <domain includeSubdomains="false">127.0.0.1</domain>
    <domain includeSubdomains="false">localhost</domain>
  </domain-config>
</network-security-config>

Running a flavor:

cd apps/reseller
flutter run --flavor dev --dart-define-from-file=env/mock.json
flutter run --flavor staging --dart-define-from-file=env/staging.json
flutter build apk --flavor staging --release --dart-define-from-file=env/staging.json
flutter build appbundle --flavor prod --release --dart-define-from-file=env/prod.json \
  --obfuscate --split-debug-info=build/symbols

Firebase projects

One Firebase project per environment, owned by HOCO's Google account, each registering both apps with the flavor's application id. A server environment and an app flavor must use the same project: FCM tokens are project-scoped, so the staging API sends with the hoco-staging service account to tokens produced by staging builds.

Firebase projectAndroid appsServer environment that sendsUsed for
hoco-devdz.hoco.reseller.dev, dz.hoco.delegate.devLocal serverFCM tests
hoco-stagingdz.hoco.reseller.staging, dz.hoco.delegate.stagingStaging and pre-productionFCM, App Distribution of test builds
hoco-proddz.hoco.reseller, dz.hoco.delegateProductionFCM

Project ids are proposals; Google may append a suffix if they are taken. Generate the configuration per flavor with the FlutterFire CLI:

dart pub global activate flutterfire_cli
cd apps/reseller
flutterfire configure --project=hoco-dev --platforms=android \
  --android-package-name=dz.hoco.reseller.dev \
  --out=lib/firebase_options_dev.dart \
  --android-out=android/app/src/dev/google-services.json
# repeat for staging and prod, then for apps/delegate

bootstrap() picks DefaultFirebaseOptions of the flavor from HOCO_ENV. Push setup and routing are on notifications and deep links.

Signing

  • Play App Signing holds the app signing key; the team only holds an upload key per app (upload-reseller.jks, upload-delegate.jks), generated once by Imad:
keytool -genkeypair -v -keystore upload-reseller.jks -alias upload \
  -keyalg RSA -keysize 2048 -validity 10000
  • android/key.properties (git-ignored) points to it locally; CI rebuilds it from secrets (ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD):
storeFile=C:/keys/hoco/upload-reseller.jks
storePassword=…
keyAlias=upload
keyPassword=…
  • The master copy of each keystore and its passwords lives in the shared password-manager vault (Mobile collection: Imad, Anis). A lost upload key is recoverable through Play support, but it blocks releases for days.
  • Fingerprints to collect once the apps exist in Play Console: SHA-256 of the app signing key (for assetlinks.json) and SHA-1 of the upload, app signing and debug keys (for the Maps API key restriction). Details in build and release.

Bootstrap commands

Toolchain. Install FVM and pin Flutter stable in the repository.

dart pub global activate fvm
fvm install stable
fvm use stable --pin          # writes .fvmrc with the exact version
dart pub global activate melos

Apps and packages.

fvm flutter create --org dz.hoco --project-name reseller --platforms android,ios apps/reseller
fvm flutter create --org dz.hoco --project-name delegate --platforms android,ios apps/delegate
fvm flutter create --project-name gallery --platforms android,web apps/gallery
for p in hoco_core hoco_api hoco_ui hoco_l10n hoco_offline hoco_maps hoco_catalog; do
  fvm flutter create --template=package "packages/$p"
done

Then add resolution: workspace to every member, write the root pubspec.yaml above and run fvm flutter pub get at the root.

Hooks and lints. analysis_options.yaml at the root (conventions); npm i -g lefthook (or Scoop on Windows), lefthook install; commitlint with the same configuration as hoco.

Flavors and environments. Gradle flavors as above, env/*.json, dev network security config, launcher icons per flavor. Check: the three flavors install side by side.

CI. .github/workflows/ci.yml running melos run format:check, melos run analyze, melos run test on every PR (quality and testing). Make it a required check once green.

Daily commands.

fvm flutter pub get            # whole workspace
melos run gen                  # after changing a freezed / riverpod / drift / retrofit file
melos run l10n                 # after editing an ARB file or enums.json
melos run mock                 # Prism on :4010 (needs the pinned contract)
melos run api:sync             # pull a new contract version, regenerate hoco_api
melos run analyze && melos run test

On this page