flutter pub get
dart run tool/generate_localization.dart # generate i18n from ARB files
dart run tool/generate_release_info.dart # generate release_info.dart (writes the `dev` sentinel locally)
flutter pub run build_runner build # generate code (drift, etc.)
flutter test # run all tests
flutter analyze # lint checkAfter changing ARB files, always regenerate: dart run tool/generate_localization.dart
Three branches participate in the release lane:
staging— integration branch. All feature PRs targetstaging, notdevelop. Same protections asdevelop: 1 approval +Analyze & Test+Visual Regression+Coverage Floor Gate.develop— pre-release. Receives changes viaauto-staging-pr.yaml, which opens astaging → developPR on every push tostaging.main— production. Receives changes viaauto-release-pr.yaml, which opens adevelop → mainPR on every push todevelop.
feature/* ──(PR)──> staging ──(auto-PR)──> develop ──(auto-PR)──> main
The auto-opened promotion PRs are idempotent — only one is open per branch pair at any time. Each one waits for the same review + CI gates as the underlying branch. Tagged releases (v*) trigger after the relevant branch receives the commit; see the Release Versioning workflow table in the README for details.
- The app is only allowed to talk to the DFX API:
api.dfx.swiss(mainnet) anddev.api.dfx.swiss(testnet/Sepolia). No other hosts. - No third-party APIs: no direct Ethereum JSON-RPC calls (Infura, Alchemy, public nodes, etc.), no block explorer APIs (Etherscan, …), no price feeds, no analytics endpoints, no third-party SDKs that call out over the network.
- If a feature needs on-chain data (e.g. native ETH balance, transaction status, token balance), add a new endpoint to
DFXswiss/apiand let the app call that endpoint. The API is the single gateway. - All network calls must go through
AppStore.httpClientwithbuildUri(_host, …)—_hostresolves to the DFX API host viaApiConfig. Do not instantiatehttp.Client/Dio/Web3Clientagainst other hosts.
Network access is one half of the gateway rule. Business decisions are the other half. The DFX API is the single source of truth for what the user is allowed to do, what state they are in, and what they should be asked to do next. The realunit-app is a rendering layer for what the API says.
- The app does not decide if a flow is allowed. The API decides. If the API accepts a call, the app must not block it pre-emptively.
- The app does not interpret status strings into business meaning. It renders what the API returns as
currentStep/nextAction/state. - The app does not duplicate backend sets/enums as gating logic. DTO mirroring for type safety is fine; local
_requiredStepNames,actionableStatuses,_minLevelForActions,_minAmountChfconstants are not. - Prompts to the user fire only when the API requests them. "Please verify yourself" appears only when the API signals a pending KYC step — never because the app inferred something from a level number or expired timestamp.
Before adding an if / switch / .filter() on API data, ask:
- Does the API already return the answer I'm computing? → use it directly.
- If I remove this local logic and render the API field 1:1, what breaks? → if "nothing", remove it; if "a missing field", extend the API.
- When local and API disagree, who wins? → the API. Always.
- UI input validation (format, required field, length) — UI concern
- Display formatting (date, currency, locale) — UI concern
- Local security gates (PIN, wallet lock, BitBox connection) — physical security boundary, cannot be API-driven
- Cryptographic operations (EIP-712 signing, key derivation) — must be local
- Deciding which KYC steps are required (
_requiredStepNames,_minLevelForActions) — the API decides viarequiredKycSteps()on its side - Deciding which step status is "actionable" or "pending" (
actionableStatuses,pendingStatuses) — the API returnscurrentStepdirectly - Min/max transaction amounts, fees, supported currencies hardcoded — must come from
/quote//fiat//assetendpoints - Routing flows based on local conditions (
if isBitbox → sellBitbox) — API signals the required workflow - Feature visibility based on derived local state (Support link only if
emailSet, Edit only if!inReview) — API returns capability flags - Pre-flight validation duplicating API rules — call the API, render its error
Extend the API, then change the app. Do not add app-side workarounds. Open an issue / PR in DFXswiss/api describing the missing field (e.g. KycStepDto.isRequired, BuyQuoteDto.minAmount, SettingsCapabilityDto.canBackup) and wait for it. Temporary local logic is technical debt that stays — every shortcut accumulates as another place the app diverges from the API.
A full audit of current violations lives in docs/api-authority-audit.md. New PRs must not add to it; ideally they reduce it.
When the API exposes a capability (boolean flag or struct on UserCapabilitiesDto / similar), the app's consumer code follows the rules below. The mirror — the rules for how the API exposes capabilities — lives in DFXswiss/api:CONTRIBUTING.md under "API Capability Design". Both sets were synthesised from the #3733 → #3761 → #3767(closed) → #3772(merged 2026-05-26) review sequence with @davidleomay.
-
Read the capability shape, don't reconstruct it. If the API ships
canEditName: bool, the page bindsonPressedtocanEditName. If it shipscreateSupportTicket: { available, missingPrerequisite? }, the tap handler routes on the discriminator. Neverif (user.mail == null)orif (user.kyc.level >= 30)as a stand-in. -
Tile/button visibility for discoverable actions is unconditional. Tiles that gate prerequisites (Support, KYC, etc.) stay visible regardless of capability state — the user discovers the action even pre-signin. The capability struct's
availablefield controls only the tap outcome (push the target page vs. push the prerequisite capture page), not the visibility. -
Map prerequisite types to UI components, not to business rules. When the capability says
missingPrerequisite: 'Email', the app pushes the email capture page. It does NOT contain a comment like "this means mail is required because support needs it" — the rule is on the backend. The app's switch is a UI dispatch, not domain logic. -
Legacy backend tolerance — capability optional, sane fallback. All new capability fields are nullable in the Dart DTO (
createSupportTicket?: CreateSupportTicketCapabilityDto). On pre-rollout backends the field is null; the consumer falls back to the unprotected direct-push. The behaviour is identical to today's behaviour (pre-capability), so the user is no worse off — the capability just adds the smarter path when the backend supports it. -
No reactive 400-handling for what a capability could pre-tell. If the backend exposes a pre-tap capability, the app must consume it. Attempting the action and reacting to a typed
BadRequestExceptionpost-submit is a regression — the user loses form data, the error-body parsing is brittle, the navigation choreography is fragile. Reactive errors are only for things the capability can't predict (network failures, transient backend issues, race conditions). -
Pair-PR discipline. App-side capability adoption happens in the same PR that deletes the local logic the capability replaces. PR title:
refactor(<scope>): consume <Capability>. PR body cites the V-ID fromdocs/api-authority-audit.mdand the API-side commit. The PR is opened after the API PR has merged ondevelop— running ahead of the API merge means the consumer sees null forever in DEV and the change effectively dead-codes itself. -
Tests pin the contract, not the implementation. Cubit tests assert that
init()withcapability == nullemits the legacy-fallback success state; withavailable: trueemits the available state; withavailable: false, missingPrerequisite: Emailemits the prerequisite-required state. Widget tests assert that taps in each state push the correct route. Don't test "if mail == null, the tile state is unavailable" — that ties the test to the backend rule, exactly what we're trying to decouple. -
Push back on capability shape that's over-engineered. Capabilities should be the minimum dynamic info to fulfil a UX requirement. Endpoint paths, HTTP methods, and i18n strings belong in Swagger / the client respectively. If a proposed API field looks like it duplicates static information, comment on the API PR asking for the minimum surface. Reference template: DFXswiss/api#3772 (comment).
The first capability that follows this design lives end-to-end across these PRs:
- API: DFXswiss/api#3772 — adds
createSupportTicket: { available, missingPrerequisite? }. - App: companion PR on this repo consuming the field —
SettingsContactPagekeeps the tile unconditional,SettingsContactCubithydrates the capability from/v2/user, the tap handler in the view routes oncapability.available/capability.missingPrerequisite.
Future capabilities follow the same shape: bool for hide-able, { available, missingPrerequisite? } for discoverable. The closed MissingPrerequisite enum grows additively as new prerequisite gates appear server-side.
lib/
di.dart # GetIt service locator setup
router.dart # GoRouter route definitions
models/ # Domain models (extend Equatable)
packages/
repository/ # Data access layer
service/dfx/
models/{resource}/dto/ # DTOs with fromJson()
{resource}_service.dart # Business logic services
screens/{screen}/
bloc/ or cubits/ # State management per screen
widgets/ # Screen-specific widgets
styles/ # Colors, TextStyles, Themes
widgets/ # Shared reusable widgets
- Colors: Always use
RealUnitColors.*orRealUnitColors.basic.white/.black. Avoid using colors frommaterial.dart(Colors.white,Colors.black,Colors.transparent, etc.) if possible. NEVER use rawColor(0x...)values. - Transparent colors:
RealUnitColors.basic.white.withValues(alpha: 0)— NEVERColor(0x00FFFFFF). - TextStyles: Always use
Theme.of(context).textTheme.*(headlineLarge/Medium/Small, bodyLarge/Medium/Small). NEVER hardcodeTextStyle(fontSize: 26, fontWeight: ...).- Mapping:
headlineLarge= h1 (30/600),headlineMedium= h2 (26/bold),headlineSmall= h4 (20/bold),bodyLarge= base (16),bodyMedium= sm (14),bodySmall= xs (12) - Any changes to TextStyles should happen via
.copyWith(...)(e.g., color, fontWeight).
- Mapping:
- Loading indicators:
CupertinoActivityIndicator— NEVERCircularProgressIndicator. - Linter: Use
analysis_options.yamlas the linter rule reference.
- Source files:
assets/languages/strings_de.arbandstrings_en.arb - Generated file:
lib/generated/i18n.dart(in .gitignore) - Keys MUST be alphabetically sorted in both ARB files.
- Always update BOTH de and en ARB files.
- Punctuation belongs in the template (
'${s.label}: $value'), NOT in the ARB value. - Before adding a new key: search existing keys first — reuse where possible.
- Avoid using
S.current(no context) in cubits/blocs. Prefer emitting typed states and resolving localization in the UI viaS.of(context). When localization is needed in functions, passBuildContextrather thanS.
- Single source of truth for a published build: the git tag. Tags are plain SemVer
vX.Y.Z— no pre-release suffix. The previousvX.Y.Z-beta.Nschema has been retired and tags carrying any suffix are rejected by the generator. - PATCH (
v1.0.X, X >= 1) is bumped automatically by.github/workflows/auto-tag.yamlon every push todevelop. MINOR / MAJOR are manual tag pushes — they mark an App-Store-update candidate. - Both release workflows ship to Test tracks only (TestFlight + Play Internal). Production promotion is done manually in the store backends, never by a tag push.
tool/generate_release_info.dartderives the in-appreleaseTag, the platform-identicalversionCodeand themarketingVersionfrom the tag. Schema:MAJOR * 10_000_000 + MINOR * 100_000 + PATCH * 1_000 + 999. The fixed+999suffix keeps new build codes strictly above the legacy beta train (highest published wasv1.0.0-beta.14→10_000_014).- Local builds carry
releaseTag = 'dev'(versionCode0) so the settings footer readsVersion devinstead of a stale pinned build number. pubspec.yaml'sversion:field has two roles:+0is a sentinel for local builds — CI always overrides--build-name/--build-numberfrom the tag. Don't bump the+Npart manually.- The
X.Y.Zpart is consumed byauto-tag.yamlas a floor for MAJOR / MINOR bumps. Patch increments come from the latest tag; pubspec is only consulted to trigger jumps. To start a new MINOR / MAJOR train (e.g.1.1.0), bump theX.Y.Zpart inpubspec.yamlondevelopand the next auto-tag will pick it up. Patch-level work needs no edit — just push to develop.
- Schema limits:
MAJOR,MINOR,PATCHin0..99. The generator hard-fails outside these bounds. Before approachingPATCH = 99on a given train, bumppubspec.yaml's MINOR (e.g.1.0.99→1.1.0) so auto-tag starts a new train. There is intentionally no safety net — surprising a CI cap is preferable to silently overflowing the version code.
See the README's "Release versioning" section for the full table and the typical patch flow.
- Bloc: For complex event-driven flows. Events are
sealed classextendingEquatable. States usefinal class— usecopyWithor distinct state classes (Initial, Loading, Success, Failure) depending on the use case. - Cubit: For simpler state. States extend
Equatable— usecopyWithor distinct state classes depending on the use case. - State files are separate from bloc/cubit files.
The app supports three wallet modes (software, bitbox, debug) with different signing capabilities. Any feature that needs an EIP-712 signature must gate on getIt<AppStore>().wallet.walletType and surface a dedicated failure state for modes that cannot sign (today: debug). See docs/wallet-modes.md for the full table and the KycSignatureUnsupportedFailure precedent.
- DTOs live in
lib/packages/service/dfx/models/{resource}/dto/ - Naming:
{Resource}Dtowithfactory fromJson(Map<String, dynamic> json) - Type casting:
json['field'] as Type— no dynamic access. - NEVER parse JSON inline in services — always create a DTO class.
- Domain models are separate from DTOs and extend
Equatable.
- Uses GoRouter. Routes defined in
lib/setup/routing/router_config.dart. - Route names are defined in typed classes:
AppRoutes,SettingsRoutes,PinRoutes,OnboardingRoutes,LegalRoutes. - Always use
pushNamed/goNamedwith route constants — NEVER hardcode route strings. - Pages should read their own dependencies from Bloc/DI — avoid passing data via route
extrawhen the page can obtain it from context.
- GetIt service locator in
lib/di.dart. - Access via
getIt<ServiceType>()— use inline, don't store in local variables unless used multiple times.
- Uses
flutter_test,bloc_test, andmocktail(NOT mockito). - Test structure mirrors
lib/structure. - Test helper at
test/helper/(providespumpApp). - For BitBox-related code, the layered test strategy (Tier 0–4) is documented in
docs/testing.md, with concrete patterns for cubit tests, widget tests, service + HTTP tests, andFakeBitboxCredentials-backed integration tests. docs/testing.mdalso lists the surface that needs an infra PR first (Drift repositories,getIt-coupled pages,path_provider-coupled cubits, the Sumsub SDK, plugin-coupled widgets). Don't try to mock around those without changing the injection point.- Service-lifecycle tests are mandatory for any service with a
Timer, observer/subscription loop, or platform/MethodChannel dependency: instantiate the real class (no mock of the service itself), swapBitboxUsbPlatform.instanceinsetUpand restore intearDown. Tests with periodic-timer or observer behaviour MUST drive time viapackage:fake_async(fakeAsynczone +async.elapse(...)). Wall-clockFuture.delayedis not acceptable for time-bound assertions.- Why: mocking the service-under-test hides timer leaks, unsubscribed listeners, and double-init bugs; wall-clock delays make tests slow and flaky.
- See:
test/packages/hardware_wallet/bitbox_service_test.dart.
- Exception surface tests are mandatory: every typed exception in
lib/(any class thatimplements Exceptionorextends Exception) MUST overridetoString()so the rendered string does not containInstance ofand is non-empty, AND MUST be enumerated in the shared surface test the moment it is introduced. When a new typed exception is added tolib/, it MUST be added to the enumeration inexception_surface_test.dartin the same PR. The test exists to catch precisely this kind of drift.- Why: exceptions surface in logs, Sentry, and user-facing error states — the Dart default
Instance of '...'is useless for debugging and unfriendly for users. - See:
test/packages/service/dfx/exceptions/exception_surface_test.dart.
- Why: exceptions surface in logs, Sentry, and user-facing error states — the Dart default
- Platform-specific code paths (USB transports, BLE lifecycle, secure storage, biometric prompts, deep links) MUST either ship an
integration_test/counterpart exercising the real plugin or vendor simulator, OR carry an inline// @no-integration-test: <reason>annotation as either a file-level dartdoc comment OR immediately above the function/method declaration.1- Why: unit tests with mocked platform channels cannot catch real-device regressions (permission prompts, OS-level lifecycle, transport quirks); the annotation makes the absence of an integration test a deliberate, reviewable decision.
- See: grep the annotation with
rg "^//\s*@no-integration-test:" lib/
- Visual-regression Goldens under
test/goldens/screens/are also the source of the 26 screenshots served athandbook.realunit.app. When you add a handbook page, you MUST add a matching Golden test AND a row in the mapping table atscripts/assemble-handbook-screenshots.sh— the handbook will not pick up a Maestro-captured PNG anymore. TheHandbook Build Checkworkflow on every PR runs the assembly script and fails loudly if a mapped Golden is missing.- Why: single source of truth — a UI regression that breaks a Golden also breaks the handbook image before either ships; eliminates the previous "two pipelines, two truths" problem.
- See:
docs/visual-regression-tests.mdsection "Handbook screenshots are sourced from Goldens".
- Prefer
StatelessWidgetunless lifecycle management is truly needed. - Extract widgets into own files if they can be graphically as well as semantically separated.
- Don't create wrapper widgets that only delegate to a child.
- Prefer
Aligninstead ofPositioned.
pubspec.yamldependencies must be alphabetically sorted.- Imports within files: alphabetically sorted. Order:
dart:→package:flutter→package:other→package:realunit_wallet/.
-
Don't leave unused imports after refactoring.
-
Don't use default parameter values that contradict business rules (e.g.,
amount = '300'when minimum is 1000). -
Don't add explanatory comments for workarounds — fix the root cause instead.
-
Don't add i18n keys without using them — remove unused keys when deleting features.
-
Avoid using
SizedBoxfor spacing in Column/Row — use thespacingproperty instead:// Bad Column(children: [Widget1(), SizedBox(height: 16), Widget2()]) // Good Column(spacing: 16, children: [Widget1(), Widget2()])
-
Don't use positional parameters for optional values in state classes — use named parameters:
// Bad const MyState(this.optionalData, {this.otherField}); // Good const MyState({this.optionalData, this.otherField});
-
Follow existing patterns. For multi-step flows (e.g., KYC), each step should have its own Page + Cubit. Don't combine multiple steps into one, pass data through state, or use inline widgets with callbacks when a separate page is the established pattern.
-
Follow the separation of concerns principle.
Footnotes
-
Activates once an
integration_test/directory exists in the repo; until then, treat option 1 as N/A and the// @no-integration-test:annotation as the documenting form. ↩