Skip to content

Release: develop -> main#325

Open
github-actions[bot] wants to merge 233 commits into
mainfrom
develop
Open

Release: develop -> main#325
github-actions[bot] wants to merge 233 commits into
mainfrom
develop

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

TaprootFreak and others added 30 commits May 15, 2026 09:21
)

## Summary
Adds 38 unit tests across six previously-untested files in
`lib/packages/utils/` and `lib/packages/wallet/`. Each spec lives in the
mirror path under `test/` per the project convention.

| File under test | Test file | Cases |
| --- | --- | --- |
| `lib/packages/utils/fast_hash.dart` |
`test/packages/utils/fast_hash_test.dart` | 5 |
| `lib/packages/utils/jwt_decoder.dart` |
`test/packages/utils/jwt_decoder_test.dart` | 7 |
| `lib/packages/wallet/payment_uri.dart` |
`test/packages/wallet/payment_uri_test.dart` | 4 |
| `lib/packages/wallet/wallet_account.dart` |
`test/packages/wallet/wallet_account_test.dart` | 7 |
| `lib/packages/wallet/wallet.dart` |
`test/packages/wallet/wallet_test.dart` | 11 |
| `lib/packages/wallet/eip7702_signer.dart` |
`test/packages/wallet/eip7702_signer_test.dart` | 4 |

## What each file covers
- **fast_hash:** FNV-1a determinism, distinct inputs differ, ordering
matters, unicode safety, empty-string offset basis.
- **jwt_decoder:** well-formed payload parsing, segment-count errors,
non-map payload rejection, all three valid base64url padding lengths,
illegal length-mod-4 rejection.
- **payment_uri:** empty-amount short form, dotted and comma-locale
amounts, preservation of decimal precision.
- **wallet_account:** BIP-44 derivation path format, deterministic
Hardhat account #0 address from the standard test mnemonic, distinct
indices, signMessage shape + determinism + sensitivity to addressIndex.
- **wallet:** `SoftwareWallet` walletType + primary/current account
identity + `selectAccount` semantics + id/name mutability; `DebugWallet`
sign refusal.
- **eip7702_signer:** hardware-credential refusal (BitBox cannot sign
EIP-7702), software-credential signing, signature determinism,
nonce-sensitivity.

## Why
Stage 2 of the coverage push tracked in the README features matrix
([#322](#322)). These six
files are pure-Dart with no platform dependencies, so they were the
cheapest meaningful coverage win available — no service mocks, no widget
rendering, no merge-conflict risk with the three in-flight test PRs
([#319](#319),
[#320](#320),
[#321](#321)).

## Test plan
- [x] `flutter analyze` clean on all six new test files (locally on
Flutter 3.38.5)
- [x] `flutter test test/packages/utils/ test/packages/wallet/` — 38 /
38 passing
- [ ] CI green on this branch
- [ ] Spot-check: coverage artifact from #323 (once that lands) shows
non-zero coverage on the six files above
## Summary
Replace the bare README with a full project-overview document modelled
on the `zk-coins/app` README. Adds:

1. **Contributing rule:** 100% test coverage on the activated surface is
required to merge into `develop`. Defensive code is exempted via `//
coverage:ignore-*`. Branch protection enforces.
2. **Coverage scope:** `lib/packages/**` + every `cubits/`/`bloc/`
directory under `lib/screens/<feature>/`. Widget files render-tested via
`testWidgets` only.
3. **Coverage infrastructure roadmap:** honest list of what still needs
to land before the rule is enforceable (CI `--coverage` step, lcov
threshold gate, branch protection, build-time feature flags, inline
ignore annotations).
4. **Features matrix:** every user-facing function, its activation
status, the triage decision (`mvp` / `defer` / `planned`), and the tests
that currently cover it.
5. **Triage gaps:** explicit list of `mvp` features still below 100%,
with links to the in-flight PRs
([#319](#319),
[#320](#320),
[#321](#321)) that close
them.
6. **Testing tiers:** ties the matrix to the 4-tier model in
[#314](#314).
7. Original **Getting started** section preserved at the end.

## Why
The repo has no source of truth for "what does this wallet actually do"
or "what is its test-coverage commitment". Tests in `test/` exist but
are unbound to features — a reviewer cannot tell at a glance whether a
feature added in a PR has the required coverage. This document closes
that gap.

The coverage rule deliberately mirrors `zk-coins/app` exactly (same
wording, same scope shape) so that reviewers familiar with one project
transfer their expectations to the other. The 4-tier testing model is
owned by [#314](#314);
this README references it but does not duplicate it.

## What this PR is _not_
- It does **not** wire `flutter test --coverage` into CI. That is item 1
of the roadmap and will land in a follow-up.
- It does **not** add branch protection. That is item 3 of the roadmap.
- It does **not** introduce build-time feature flags. That is item 4 of
the roadmap and is a prerequisite for the rule being realistic across
all features.
- It does **not** add any new tests — the in-flight test PRs
([#319](#319),
[#320](#320),
[#321](#321)) own that
work.

Until the roadmap items land, the rule is aspirational, explicitly noted
as such in the README, and not yet a merge blocker.

## Test plan
- [ ] Visual review of the rendered README on GitHub (tables align, all
links resolve)
- [ ] Confirm every `widget`-tagged row points at an existing
`*_test.dart` file in `test/`
- [ ] Confirm every "in flight" reference points at an open PR
- [ ] Confirm "Coverage scope" wording matches what we want to measure
once the lcov gate lands
## Summary
Adds `flutter test --coverage` to the existing PR workflow and uploads
the (filtered) `lcov.info` as a CI artifact.

- `flutter test --coverage` replaces the plain `flutter test` invocation
- `coverage/lcov.info` is filtered with `lcov --remove` to drop
generated files (`lib/generated/**`) and `lib/main.dart` so the artifact
reflects only the activated surface
- The filtered report is uploaded as artifact `coverage-lcov` on every
run (`if: always()`)
- `lcov --summary` prints to the CI log so contributors can eyeball the
line/function/branch numbers without downloading the artifact

## Why
Step 1 of the coverage infrastructure roadmap documented in PR #322.
Without measurement, the 100% rule is unverifiable; with measurement but
no threshold, this PR is safe to merge today and lays the foundation for
the threshold gate in a follow-up.

## What this PR is _not_
- Does **not** enforce any threshold. The build still passes regardless
of coverage %. Hard-gating below 100% would block every open PR today
(current coverage ratio ≈ 10%).
- Does **not** add any new tests.
- Does **not** change which files exist in `test/`.

## Test plan
- [ ] CI run completes green
- [ ] `coverage-lcov` artifact is present on the PR's checks page
- [ ] `lcov --summary` line of the CI log shows non-zero coverage on
`lib/packages/**`
- [ ] Filtered report excludes `lib/generated/**`
Phase 0 of #314 — closes the test-coverage gap PR #312 left behind,
including the cross-call generation-counter regression that motivated
#315 / #317.

## Summary

#312 introduced significant new gating logic in `KycCubit`
(`_legalDisclaimerAccepted`, `_bitboxConfirmed`,
`_emailRegistrationAttempted`, `_runGeneration`, the 30 s timeout
wrapper, the 3 new states `KycAccountMergeRequested` /
`KycUnsupportedStepFailure` / extended `KycStep`, the post-sign
\`ApiException\` blanket-swallow in `KycRegistrationSubmitCubit`, and
the empty-signature guards in `Eip712Signer` + `DFXAuthService`) — all
of it shipped with zero unit tests. #315 surfaced a follow-up race in
the timeout flag a few hours later, fixed by #317, and #317 itself notes
that the regression test for it would land here.

This PR adds that scaffold.

## What's covered

| File | Cases | Notes |
|---|---|---|
| \`test/screens/kyc/cubits/kyc/kyc_cubit_test.dart\` | 19 | Every
\`KycCubit\` state transition: email auto-registration + recursion
guard, legalDisclaimer / BitBox gates, AccountMergeRequested,
KycPending, _continueKyc → KycUnsupportedStepFailure, KycCompleted,
returning-user-must-sign, TFA via \`statusCode == 403\` *or* \`code ==
'TFA_REQUIRED'\`, unrelated ApiException / generic exception →
KycFailure, custom \`requiredLevel\`, and the cross-call
generation-counter regression for #315 / #317 |
|
\`test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart\`
| 8 | Happy path, BitboxRequired, the \`ApiException\`-after-sign
blanket-swallow path (account-exists / merge), generic post-sign failure
(\`Signature was empty\` / network / parse), missing-mail, getUser
failure, retrySubmit success + still-disconnected |
| \`test/packages/service/dfx/dfx_auth_service_test.dart\` | 6 |
\`getSignature\` cache hit / address mismatch / fresh sign + cache /
empty + \`'0x'\` guards, \`getAuthToken\` cache short-circuit. **Gap:**
3 min sign timeout — needs \`fake_async\`, called out as a TODO |
| \`test/packages/wallet/eip712_signer_test.dart\` | +2 |
Empty-signature and \`'0x'\` guards for the BitBox cancel /
BLE-disconnect paths |

Stack: \`bloc_test\` + \`mocktail\` (already in \`dev_dependencies\`).
Test layout mirrors \`lib/\` per CONTRIBUTING.md.

## #315 / #317 regression test

The retry-race test resolves the assertion that #317's PR description
deferred to #314:

> Unit test for the retry-race scenario will be added in #314 Phase 0,
once the \`kyc_cubit_test.dart\` scaffold lands.

It hangs call 1's \`getKycStatus\`, fires call 2 against a fresh
response, then resolves call 1's late response and asserts (a) the
listener never sees a state after \`KycCompleted\` and (b) \`cubit.state
== KycCompleted()\` — i.e. the generation guard kills the stale leak.

## Test plan

- [x] \`flutter analyze\` — clean (only the pre-existing \`i18n.dart\`
generated-code warning)
- [x] \`flutter test\` — **223 / 223 passing** locally (up from 188; +35
net new cases)
- [x] \`dart format\` — applied
- [x] No production-code changes; only new tests + an extension to the
existing \`eip712_signer_test.dart\`
## Summary
Stage 3 of the coverage push. Adds 38 unit tests across four
previously-untested service files in \`lib/packages/service/\` that have
no DFX backend coupling. \`mocktail\` mocks for the repository / bitbox
dependencies; no real I/O.

| Service | Test file | Cases |
| --- | --- | --- |
| \`session_cache.dart\` |
\`test/packages/service/session_cache_test.dart\` | 9 |
| \`settings_service.dart\` |
\`test/packages/service/settings_service_test.dart\` | 6 |
| \`app_store.dart\` | \`test/packages/service/app_store_test.dart\` | 6
|
| \`wallet_service.dart\` |
\`test/packages/service/wallet_service_test.dart\` | 17 |

## What each file covers
- **session_cache:** auth-token in-memory-only lifecycle (no disk
write); signature/address persistence through \`CacheRepository\`;
\`loadSignature\` preserves in-memory state when already set; tolerates
missing repo entries; \`clear\` wipes both keys, auth token, and memory.
- **settings_service:** terms / software-terms read + write delegation;
the two flags are independent.
- **app_store:** wallet-not-set throws; primaryAddress derived through
current account; selectAccount-driven address change; \`apiConfig\`
getter re-evaluates on every access (network mode can switch at
runtime); sessionCache passthrough.
- **wallet_service:** \`createSeedWallet\` (bip39 generation +
persistence, does NOT mark current); \`restoreWallet\` (seed persistence
+ marks current); \`createDebugWallet\` (view-wallet path);
\`getWalletById\` (software / debug branches + null-repo error);
\`setCurrentWallet\`; \`getCurrentWallet\` (resolves through id);
\`deleteCurrentWallet\`; \`hasWallet\` (both branches); \`validateSeed\`
(valid / invalid / empty / wrong-checksum).

## Excluded (with reasons)
- **biometric_service.dart** — the \`LocalAuthentication\` instance is
constructed in the field initializer with no seam. Testing requires
either a prod refactor (out of scope here) or platform-channel test
plumbing. Flagged in the README features matrix as a triage gap.
- **transaction_history_service.dart** — hits \`app_store.httpClient\`
(a final \`http.Client\` field) which is not currently injectable. Same
constraint as the DFX services.
- **price_service.dart** — abstract interface only; no implementation to
test yet.
- **wallet_service \`createBitboxWallet\` / getWalletById bitbox
branch** — belongs to Tier 1 with \`FakeBitboxCredentials\` (now landed
via #319/#320). Will be covered in a follow-up.

## Test plan
- [x] \`flutter analyze\` clean (locally on Flutter 3.38.5)
- [x] \`flutter test test/packages/service/\` — 38 / 38 passing on these
new files
- [ ] CI green
## Summary
Stage 4 of the coverage push. Adds bloc_test specs for five screen-level
cubits/blocs that previously only had widget-level coverage. All run in
pure Dart (no widget pumping); mocktail for the service / repository /
SoftwareWallet boundaries.

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`legal_disclaimer_cubit.dart\` |
\`test/screens/legal/cubit/legal_disclaimer_cubit_test.dart\` | 7 |
| \`restore_wallet/cubit/validate_seed/validate_seed_cubit.dart\` |
\`test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart\` | 7
|
|
\`transaction_history/cubits/filter/transaction_history_filter_cubit.dart\`
|
\`test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart\`
| 6 |
| \`verify_seed/cubit/verify_seed_cubit.dart\` |
\`test/screens/verify_seed/cubit/verify_seed_cubit_test.dart\` | 6 |
| \`pin/bloc/setup_pin/setup_pin_cubit.dart\` |
\`test/screens/pin/setup_pin_cubit_test.dart\` | 11 |

## What each file covers

- **legal_disclaimer_cubit:** initial step-0 state, nextStep advance,
full walk to last step, onComplete callback fires on last step (no
emit), no-op without callback on last step, previousStep moves back,
no-op at step 0.
- **validate_seed_cubit:** initial uncomplete; \`checkSeedLength\` for
12 valid words / fewer-than-12 / 12 words with an out-of-wordlist token
/ extra inner whitespace tolerated; \`validateSeed\` delegates to
\`WalletService\` for both branches.
- **transaction_history_filter_cubit:** subscribes to the repo stream
with the configured asset + address, default 1-year-back \`startDate\`,
stream pushes populate \`all\` + \`filtered\`, date-window filter
narrows \`filtered\` without touching \`all\`, boundaries are inclusive,
filter re-applies on subsequent stream emissions.
- **verify_seed_cubit:** 4 random ascending word indices within seed
length, debug-mode pre-fill, \`canVerify\` reflects all four slots
filled, \`updateWord\` trims + lowercases + clears \`hasError\`,
\`verify\` returns true and marks the wallet current on match,
\`verify\` returns false / flags \`hasError\` / does NOT mark current on
mismatch.
- **setup_pin_cubit:** initial state, \`addDigit\` appends / 6-digit
cap, \`deleteDigit\` / no-op on empty, create→confirm transition on 6th
digit, matching confirm-pin persists salt + hash and emits
\`isComplete\` (exercises real PBKDF2 via \`compute()\`), mismatching
confirm-pin resets + flags \`mismatch\`, \`reset\` returns to initial,
\`isBiometricAvailable\` + \`enableBiometrics\` passthrough to
\`BiometricService\`.

## Notes
- The \`setup_pin_cubit\` matching-pin test runs a real **600k-iteration
PBKDF2** through \`compute()\`. On the Flutter-test isolate shim this
takes ~12 s on a Mac Studio. The test uses a 30 s timeout to keep CI
honest without flaking.
- \`SecureStorage.setPinSalt\` takes a \`Uint8List\`, which is a
restricted type — a \`Fake\` subclass is illegal, so the mocktail
fallback is registered with a real \`Uint8List(0)\` instance instead.

## Excluded (and why)
- \`pin/bloc/verify_pin/verify_pin_cubit.dart\` — talks to
\`SecureStorage.verifyPin\` which already exercises PBKDF2. Adding a
full happy-path test would compound the slow-test cost. Will be covered
in a follow-up.
- \`restore_wallet/cubit/restore_wallet_cubit.dart\` — coupled to
\`WalletService.restoreWallet\` (already covered in
\`wallet_service_test.dart\`) plus screen navigation; small marginal
value here, fits better in a Tier 1 integration test.
- Buy / sell / KYC cubits — owned by other recent PRs (#312/#319/#321
area) and would invite merge conflicts.
- Bitbox-coupled cubits (\`sell_bitbox\`, \`hardware_connect_bitbox\`) —
belong with Tier 1 \`FakeBitboxCredentials\` (now landed via #319/#320)
in a follow-up.

## Test plan
- [x] \`flutter analyze test/screens/\` clean
- [x] \`flutter test\` on all 5 new files — 37 / 37 passing (~8 s total
locally)
- [ ] CI green
## Summary
Stage 5 of the coverage push. Adds 32 unit tests for the PIN unlock
cubit and three previously-untested DFX backend services. mocktail mocks
for AppStore + repository boundaries; \`http.testing.MockClient\` for
the HTTP surface.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart\` |
\`test/screens/pin/verify_pin_cubit_test.dart\` | 18 |
| \`lib/packages/service/dfx/dfx_country_service.dart\` |
\`test/packages/service/dfx/dfx_country_service_test.dart\` | 5 |
| \`lib/packages/service/dfx/dfx_blockchain_api_service.dart\` |
\`test/packages/service/dfx/dfx_blockchain_api_service_test.dart\` | 5 |
| \`lib/packages/service/dfx/dfx_faucet_service.dart\` |
\`test/packages/service/dfx/dfx_faucet_service_test.dart\` | 4 |

## What each file covers
- **verify_pin_cubit:** initial state; \`addDigit\` / \`deleteDigit\`
including no-ops at boundaries + while \`VerifyPinTemporarilyLocked\` /
\`VerifyPinLocked\`; correct pin resets lockout and emits
\`VerifyPinSuccess\` (real 600k-iter PBKDF2 via \`compute()\`);
wrong-pin first-attempt path; \`enableLockout: false\` never persists
attempts; 5th wrong attempt triggers a 1-minute temporary lockout;
reaching \`permanentLockoutThreshold\` emits \`VerifyPinLocked\` and
skips the temporary-lockout write; \`onLockExpired\` preserves
\`failedAttempts\`; \`checkBiometricAvailability\` for the in-window /
expired / threshold / success / fail / unavailable branches.
- **dfx_country_service:** list mapping from the DTO + cache (single
fetch even on repeated reads), non-200 throws, case-insensitive symbol
lookup, unknown-symbol error, public \`cachedCountries\` field is
populated post-fetch.
- **dfx_blockchain_api_service:** POST shape (address + Bearer JWT +
chain name + asset id), testnet flips \`blockchain\` to \`'Sepolia'\`,
empty balances list returns \`0.0\`, 201 acceptance in addition to 200,
\`ApiException\` on non-2xx with the JSON body.
- **dfx_faucet_service:** POST + Bearer JWT to \`/v1/faucet\`, 201
acceptance, \`ApiException\` on non-2xx, **documents** the current
behaviour of sending the literal string \`'Bearer null'\` when no auth
token is set — useful as a regression marker if/when the service grows a
guard.

## Notes
- The verify-pin happy-path test exercises a real PBKDF2 (600k
iterations) via \`compute()\`. On the Flutter-test isolate shim this
takes a few seconds; the test uses a 30 s timeout, same approach as in
PR #327's setup_pin test.
- \`MockClient\` lets us stub \`AppStore.httpClient\` without giving the
service its own real \`http.Client\`; the production code is unchanged.

## Excluded (and why)
- \`dfx_kyc_service\`, \`real_unit_registration_service\` — overlap with
the KYC cubits that #319 already covers; want to avoid a stack on top of
that work mid-review.
- \`real_unit_account_service\`, \`transaction_history_service\` — hit
\`appStore.wallet.currentAccount.primaryAddress.address.hexEip55\`,
which needs a real \`SoftwareWallet\` plumbed through the mock. Doable
but more setup than the surface justifies; will be covered in a
follow-up alongside hook tests.
- \`dfx_brokerbot_service\`, \`real_unit_sell_payment_info_service\` —
possibly touched by #321 (dashboard buy/sell auth). Hold to avoid
review-time conflicts.
- \`biometric_service\`, \`price_service\` — same constraints documented
in #326.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 32 / 32 passing locally
- [ ] CI green
…sts) (#329)

## Summary
Stage 6 of the coverage push. Adds 23 unit tests for two more DFX
services and two screen-level cubits whose only previous coverage was at
the widget layer.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/dfx_bank_account_service.dart\` |
\`test/packages/service/dfx/dfx_bank_account_service_test.dart\` | 7 |
| \`lib/packages/service/dfx/dfx_price_service.dart\` |
\`test/packages/service/dfx/dfx_price_service_test.dart\` | 8 |
| \`lib/screens/settings_contact/cubit/settings_contact_cubit.dart\` |
\`test/screens/settings_contact/settings_contact_cubit_test.dart\` | 4 |
|
\`lib/screens/support/cubits/support_tickets/support_tickets_cubit.dart\`
| \`test/screens/support/cubits/support_tickets_cubit_test.dart\` | 4 |

## What each file covers
- **dfx_bank_account_service:** \`getBankAccounts\` GET shape (path, JWT
header, list mapping) + \`ApiException\` on non-2xx;
\`createBankAccount\` POST body, optional-label omission, error path;
\`updateBankAccount\` PUT to \`/v1/bankAccount/{id}\` with only the
provided fields (also pins the \`isDefault → "default"\` wire mapping).
- **dfx_price_service:** \`getPriceOfAsset\` CHF / EUR scaled by 100
(rappen / cents); \`getPriceChart\` maps each entry with asset, scaled
price, and UTC time; \`getChfToEurRate\` returns \`eur / chf\` or
\`0.0\` when chf == 0 (no division by zero); all three paths throw on
non-200.
- **settings_contact_cubit:** Success with \`emailSet: true\` /
\`false\`, Failure on async-throw, and a manual \`init()\` call
recovering from a transient failure.
- **support_tickets_cubit:** Loaded with DTO → \`SupportIssue\` mapping,
Loaded(empty), Error on async-throw, and a manual \`loadTickets()\` call
recovering from a transient failure.

## Notes
- Both cubits fire their work (\`init()\` / \`loadTickets()\`) in the
constructor, so by the time a stream subscriber attaches the synchronous
Loading emit has already passed on the broadcast stream. The tests
therefore assert the **final state** via \`stream.firstWhere\` rather
than the full sequence — a deliberate documented choice in the test file
headers.
- Mocked \`getUser\`/\`getTickets\` use \`thenAnswer((_) async => throw
…)\` instead of \`thenThrow\` so the throw goes through a microtask
boundary (otherwise the cubit's \`catch\` block emits Failure before any
listener can hear it).
- Service tests use \`http.testing.MockClient\` against
\`AppStore.httpClient\`, same pattern as #328.

## Excluded (and why)
- \`dfx_kyc_service\` — large surface (≈ 260 lines), heavy overlap with
the KYC cubit logic already covered by #319; would invite review-time
conflicts.
- \`dfx_brokerbot_service\`, \`real_unit_sell_payment_info_service\` —
likely touched by #321 (dashboard buy/sell auth).
- \`transaction_history_service\`, \`real_unit_account_service\` —
depend on \`AppStore.wallet.currentAccount\` plumbing; will be covered
alongside the wallet-coupled hook tests in a follow-up.
- \`support_chat_cubit\`, \`support_create_ticket_cubit\` — share
\`DfxSupportService\` with this PR; held back to keep the diff focused.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 23 / 23 passing locally
- [ ] CI green
## Summary
Stage 7 of the coverage push. Adds 34 unit tests for the remaining
support cubits and three settings-user-data edit cubits.

| Cubit under test | Test file | Cases |
| --- | --- | --- |
| \`support/cubits/support_chat/support_chat_cubit.dart\` |
\`test/screens/support/cubits/support_chat_cubit_test.dart\` | 6 |
|
\`support/cubits/support_create_ticket/support_create_ticket_cubit.dart\`
| \`test/screens/support/cubits/support_create_ticket_cubit_test.dart\`
| 9 |
|
\`settings_user_data/subpages/edit_name/cubit/settings_edit_name_cubit.dart\`
|
\`test/screens/settings_user_data/subpages/settings_edit_name_cubit_test.dart\`
| 8 |
|
\`settings_user_data/subpages/edit_phone_number/cubit/settings_edit_phone_number_cubit.dart\`
|
\`test/screens/settings_user_data/subpages/settings_edit_phone_number_cubit_test.dart\`
| 3 |
|
\`settings_user_data/subpages/edit_address/cubit/settings_edit_address_cubit.dart\`
|
\`test/screens/settings_user_data/subpages/settings_edit_address_cubit_test.dart\`
| 8 |

## What each file covers
- **support_chat_cubit:** Loaded with the mapped ticket; Error on
\`getTicket\` throw; \`sendMessage\` no-op when not in Loaded state and
for whitespace-only input; happy-path posts the message + re-fetches the
ticket (2 \`getTicket\` calls verified); service failure resets
\`isSending=false\` while keeping Loaded.
- **support_create_ticket_cubit:** initial state; \`selectType\` resets
reason to \`.other\`; \`selectReason\` preserves type; \`updateMessage\`
field-only; \`canSubmit\` gate matrix (type-null / reason-null / empty /
whitespace / submitting → all false); \`submit\` no-op when not
submittable; happy-path forwards type + reason + the mapped \`name\` for
both \`bugReport\` and \`genericIssue\` (pins the \`_getTicketName\`
switch); failure captures the error.
- **settings_edit_name_cubit:** Ready URL, Pending on \`inReview\`,
Failure on API throw, Failure on missing URL, \`refresh\` recovers;
\`submitName\` no-op when not Ready, posts the form payload + emits
Success, Failure on \`setData\` throw.
- **settings_edit_phone_number_cubit:** initial state; happy-path emits
\`[Submitting, Success]\` and forwards \`{'phone': N}\`; service throw
yields \`[Submitting, Failure]\` with the error message.
- **settings_edit_address_cubit:** same shape as edit_name plus two
address-specific pins — the nested payload (\`address.country.id\`) and
\`houseNumber\` is omitted from the payload when empty.

## Notes
- Cubits that fire work in their constructor (\`support_chat\`,
\`settings_edit_name\`, \`settings_edit_address\`) use
\`stream.firstWhere\` to await the final state instead of \`blocTest\`'s
sequence-based expect — same pattern as #329, with a one-line comment in
each test file header.
- \`SupportCreateTicketState.copyWith\` uses \`?? this.x\`, so
\`copyWith(selectedType: null)\` does **not** clear the field. The
\`canSubmit\` matrix builds the failing variants as fresh state objects
to avoid that footgun, and the rationale is on the test.

## Excluded (and why)
- \`settings_user_data_cubit.dart\` — touches three services + country
lookups + multi-path KYC step status; will be covered in a follow-up
alongside hook tests.
- \`settings_tax_report_cubit.dart\` — uses \`getTemporaryDirectory()\`
+ real \`File\` IO; needs path_provider platform-channel plumbing or a
refactor.
- \`dashboard\` blocs, \`create_wallet\` cubit, \`restore_wallet_cubit\`
— left for the next stage.
- \`dfx_kyc_service\` — covered indirectly via the edit cubits in this
PR; a dedicated test of its surface would overlap heavily with #319's
KYC cubit tests.

## Test plan
- [x] \`flutter analyze\` on all five new files — clean
- [x] \`flutter test\` — 34 / 34 passing locally
- [ ] CI green
…21 tests) (#331)

## Summary
Stage 8 of the coverage push. Adds 21 unit tests across five
previously-untested cubits.

| Cubit under test | Test file | Cases |
| --- | --- | --- |
| \`create_wallet/bloc/create_wallet_cubit.dart\` |
\`test/screens/create_wallet/create_wallet_cubit_test.dart\` | 4 |
| \`restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart\` |
\`test/screens/restore_wallet/restore_wallet_cubit_test.dart\` | 3 |
| \`debug_auth/cubit/debug_auth_cubit.dart\` |
\`test/screens/debug_auth/debug_auth_cubit_test.dart\` | 7 |
| \`dashboard/bloc/balance_cubit.dart\` |
\`test/screens/dashboard/balance_cubit_test.dart\` | 4 |
| \`dashboard/bloc/pending_transactions_cubit.dart\` |
\`test/screens/dashboard/pending_transactions_cubit_test.dart\` | 3 |

## What each file covers
- **create_wallet_cubit:** initial hides the seed + has no wallet,
\`createWallet\` stores the new \`SoftwareWallet\` (pinning the
\`'Obi-Wallet-Kenobi'\` default name), \`toggleShowSeed\` flips and
returns to hidden after two toggles, \`toggleShowSeed\` preserves the
wallet field.
- **restore_wallet_cubit:** initial state, \`restoreWallet\`
canonicalises mixed whitespace (\` x y \` → \`x y\`) before delegating
to \`WalletService.restoreWallet\`, the interim \`isLoading=true\` state
is observable.
- **debug_auth_cubit:** seeds \`address\` + \`savedSignature\` from the
service; empty-address fallback when service has none;
\`fetchSignMessage\` success + error; \`authenticate\` success +
uses-state-address + error.
- **balance_cubit:** initial zero-balance state shape \`(chain,
contract, wallet, asset)\`; subscribes to
\`BalanceRepository.watchBalance\` on init; emits each pushed balance
through the stream; \`close()\` cancels the subscription cleanly.
- **pending_transactions_cubit:** initial empty list; emits fetched list
on construction; falls back to empty list (not state-error) when the
service throws.

## Excluded (and why)
- **dashboard_bloc** + transaction-history sub-cubits + portfolio/price
chart cubits — pull in multiple services or larger event-driven flows;
would dwarf this PR.
- **transaction_history_receipt_cubit** / **settings_tax_report_cubit**
— both use \`getTemporaryDirectory()\` + real \`File\` IO; needs
path_provider platform-channel plumbing.
- **sell_***, **sell_bitbox_***, **hardware_connect_bitbox_*** —
Bitbox-coupled or DFX-sell-flow-coupled; held back to avoid review
conflicts with #321 area.
- **transaction_history_filter_cubit** — already covered by #327.

## Test plan
- [x] \`flutter analyze\` on the five new files — clean
- [x] \`flutter test\` — 21 / 21 passing locally
- [ ] CI green
…oc (+22 tests) (#333)

## Summary
Stage 9 of the coverage push. Adds 22 unit tests for the remaining
dashboard cubits/bloc plus the global settings bloc.

| Cubit / Bloc under test | Test file | Cases |
| --- | --- | --- |
| \`dashboard/bloc/price_chart/price_chart_cubit.dart\` |
\`test/screens/dashboard/price_chart_cubit_test.dart\` | 4 |
| \`dashboard/bloc/portfolio_chart/portfolio_chart_cubit.dart\` |
\`test/screens/dashboard/portfolio_chart_cubit_test.dart\` | 5 |
| \`dashboard/bloc/dashboard_transaction_history_cubit.dart\` |
\`test/screens/dashboard/dashboard_transaction_history_cubit_test.dart\`
| 4 |
| \`dashboard/bloc/dashboard_bloc.dart\` |
\`test/screens/dashboard/dashboard_bloc_test.dart\` | 3 |
| \`settings/bloc/settings_bloc.dart\` |
\`test/screens/settings/settings_bloc_test.dart\` | 6 |

## What each file covers
- **price_chart_cubit:** empty-input zero-window state, all-period spots
scaled by 100 + 10% Y-padding, \`selectPeriod\`-same is a no-op (no
emit), \`oneWeek\` filter narrows to recent points.
- **portfolio_chart_cubit:** empty-input zero-window state, all-period
scaling + 6 horizontal-line values, flat-value series spreads via the 5%
floor (no Y-collapse — pins the \`average * 0.05\` lower bound and the
rounding to nice numbers \`{1,2,5,10}\`), \`selectPeriod\`-same no-op,
\`oneWeek\` narrows visibleSpots.
- **dashboard_transaction_history_cubit:** initial empty list,
subscribes to \`watchTransactionsOfAssets\` with limit \`3\`, forwards
every stream emission into state, \`close()\` cancels the subscription.
- **dashboard_bloc:** initial state carries the supplied currency, the
constructor \`refresh()\` populates price + priceChart +
portfolioHistory via the services, \`CurrencyChangedEvent\` updates
state and re-fetches all three datasets in the new currency.
- **settings_bloc:** initial state reads through the repo,
\`SetLanguageEvent\` writes \`'de'\` + emits, \`SetCurrencyEvent\`
writes \`'EUR'\` + emits, \`SetNetworkModeEvent\` writes + calls
\`getNewAuthToken\` + emits, \`ToggleHideAmountEvent\` flips both ways,
a single toggle sets \`hideAmounts=true\`.

## Notes
- The \`dashboard_bloc\` CurrencyChangedEvent test attaches a listener
BEFORE adding the event because \`Bloc.stream\` is broadcast (no replay)
and the event-driven re-fetch can complete before a follow-on
\`firstWhere\` subscribes — same constraint we've now hit a few times in
this push.

## Excluded (and why)
- \`dashboard_bloc\` refresh after a service throw — non-trivial to test
cleanly because the bloc lets the exception propagate out of the handler
(which then surfaces as an unhandled bloc error in tests). Leaving as a
follow-up.
- \`transaction_history_receipt_cubit\`,
\`transaction_history_multi_receipt_cubit\`,
\`settings_tax_report_cubit\` — all use \`getTemporaryDirectory()\` +
real \`File\` IO.
- Buy / sell / sell_bitbox / hardware_connect_bitbox cubits — touched by
the still-open PRs (#321 dashboard buy actions, #332 bitbox sign
hardening); held back to avoid review conflicts.
- \`settings_user_data_cubit\` — coordinates 3 services + country-lookup
branches; deserves its own focused PR.

## Test plan
- [x] \`flutter analyze\` on all five new files — clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
## Summary
Stage 10 of the coverage push. Adds 21 unit tests for the two remaining
easy-to-mock DFX backend services.

| Service under test | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/dfx_support_service.dart\` |
\`test/packages/service/dfx/dfx_support_service_test.dart\` | 9 |
| \`lib/packages/service/dfx/dfx_brokerbot_service.dart\` |
\`test/packages/service/dfx/dfx_brokerbot_service_test.dart\` | 12 |

## What each file covers
- **dfx_support_service:** \`getTickets\` GET shape (path, Bearer JWT) +
ApiException on non-200; \`getTicket\` by uid + ApiException;
\`createTicket\` POST body (type/reason/name + optional message) + omits
message when null + requires status \`201\` (200 is rejected as
ApiException); \`sendMessage\` POST shape + ApiException on non-201.
\`getAuthToken\` is short-circuited by pre-populating
\`sessionCache.authToken\` so the signing flow stays out of these unit
tests.
- **dfx_brokerbot_service:** \`getBuyPrice\` GET + currency-code query +
invalid-input guards (non-numeric, zero, negative, non-200);
\`getBuyShares\` GET + currency + invalid-input guards; \`getSellPrice\`
with Bearer JWT + ApiException on non-200 + the invalid-input case skips
the HTTP call entirely; same matrix for \`getSellShares\`.

## Notes
- Same mocktail + \`http.testing.MockClient\` pattern as the previous
DFX-service PRs (#326 / #328 / #329).
- The "invalid input never reaches HTTP" assertions are a small but
meaningful contract: callers can rely on these methods to fail fast
before any network round-trip.

## Excluded (deferred)
- \`settings_user_data_cubit\` was on the original Stage-10 plan but
coordinates 3 services + Country lookups + multi-branch KYC-step-status
handling. It deserves its own focused PR rather than tagging it onto
these two service tests.
- \`dfx_kyc_service\` — held back to avoid review conflicts with open PR
#332 (KYC routing / bitbox sign hardening).
- Buy / sell / Bitbox cubits — held back while #321 and #332 are open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 21 / 21 passing locally
- [ ] CI green
## Summary
Stage 11 of the coverage push.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/real_unit_wallet_service.dart\` |
\`test/packages/service/dfx/real_unit_wallet_service_test.dart\` | 3 |
| \`lib/packages/service/dfx/real_unit_pdf_service.dart\` |
\`test/packages/service/dfx/real_unit_pdf_service_test.dart\` | 9 |
| \`lib/screens/home/bloc/home_bloc.dart\` |
\`test/screens/home/home_bloc_test.dart\` | 5 |

## What each file covers
- **real_unit_wallet_service:** \`getWalletStatus\` GET shape with the
Bearer JWT, parses both \`isRegistered\` branches when \`userData\` is
null, throws ApiException on non-200.
- **real_unit_pdf_service:** \`getBalanceReport\` POSTs the
BalancePdfDto payload (\`address\`, currency code, **uppercased**
language code); accepts 201; ApiException on non-2xx.
\`getTransactionsReceipt\` (multi) posts \`txHashes\` + currency,
defaults to CHF, ApiException on non-2xx. \`getTransactionReceipt\`
(single) posts \`txHash\` + currency, defaults to CHF, ApiException on
non-2xx.
- **home_bloc:** initial \`CheckWalletExistsEvent\` for three branches:
no wallet → \`onboardingCompleted\` forced false; wallet+terms accepted
→ \`onboardingCompleted=true\`; wallet without terms →
\`onboardingCompleted=false\`. \`CompleteOnboardingEvent\` writes
\`termsAccepted=true\` and emits. \`AcceptSoftwareTermsEvent\` writes
\`softwareTermsAccepted=true\` and emits.

## Notes
- The PDF DTOs serialize \`txId\` as \`txHash\` on the wire (single) and
\`txIds\` as \`txHashes\` (multi). The tests pin these names explicitly
to lock the wire contract.
- \`Language\` codes are uppercased in the balance-pdf payload
(\`Language.de\` → \`'DE'\`). Also pinned.

## Excluded (and why)
- Other \`home_bloc\` handlers (\`LoadCurrentWallet\`,
\`DeleteCurrentWallet\`, \`LoadWallet\`, \`SyncWalletServices\`,
\`DebugAuthComplete\`) all touch \`BalanceService.startSync\` /
\`TransactionHistoryService.apiBasedSync\` /
\`DfxWidgetService.getAuthToken\` plus \`AppStore.wallet\` mutation.
Each is meaningful but needs more dependency plumbing — deserves its own
focused PR.
- \`settings_user_data_cubit\` — still deferred (coordinates 3 services
+ Country lookups + multi-branch KYC step status); will land separately.

## Test plan
- [x] \`flutter analyze\` on all three new files — clean
- [x] \`flutter test\` — 17 / 17 passing locally
- [ ] CI green
…ts) (#336)

## Summary
Stage 12 of the coverage push. Covers the previously-deferred
\`settings_user_data_cubit\` (3-service coordination + Country lookups)
and the read-side of \`transaction_history_service\`.

| File under test | Test file | Cases |
| --- | --- | --- |
| \`lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart\`
| \`test/screens/settings_user_data/settings_user_data_cubit_test.dart\`
| 6 |
| \`lib/packages/service/transaction_history_service.dart\`
(\`fetchPendingTransactions\` only) |
\`test/packages/service/transaction_history_service_test.dart\` | 6 |

## What each file covers
- **settings_user_data_cubit:** the cubit fans out to
\`RealUnitWalletService.getWalletStatus\` and
\`DfxKycService.getKycStatus\` in parallel, then either falls back to
\`getUser\` (when userData is missing) or runs two more
\`getCountryBySymbol\` lookups. Tests pin:
- Full Success when userData is present (with nationality +
addressCountry country lookups resolved to distinct \`Country\`
instances).
- \`pendingSteps\` only contains the three change steps
(name/address/phone) that are in \`inReview\` — other inReview steps
(e.g. \`contactData\`) are ignored.
- \`userData == null\` + \`getUser\` returns mail → \`Success(email)\`,
no country lookups happen.
- \`userData == null\` + \`getUser\` throws → \`Success()\` with both
userData and email null.
- Failure when \`getWalletStatus\` throws (the \`Future.wait\`
propagation).
- Failure when \`getCountryBySymbol\` throws on a userData with a
country code.
- **transaction_history_service.fetchPendingTransactions:** no auth
token short-circuits to \`[]\` without any HTTP call (verified via a
MockClient that records calls); GET shape with the Bearer JWT to
\`/v1/transaction/detail\`; non-200 returns \`[]\` (does not throw —
intentional UX); filters out \`Completed\`/\`Failed\`/\`Returned\`
(\`isPending=false\` per the enum extension); filters out transactions
whose \`sourceAccount\` / \`targetAccount\` don't match the current
wallet; wallet-match is **case-insensitive** (covers the lower-casing on
both sides).

## Notes
- \`RealUnitUserDataDto.type\` must use \`'HUMAN'\` / \`'CORPORATION'\`
(the jsonName values of \`RegistrationUserType\`), not a friendly label
— \`fromName\` throws \`StateError\` otherwise.
- Mocktail's \`stream.firstWhere\` pattern is reused for the cubit
(constructor fires \`getUserData\` synchronously, so we can't use
\`blocTest\`'s sequence model reliably here — same as #329, #330, #333).

## Excluded (and why)
- \`transaction_history_service.apiBasedSync\` — writes through
\`TransactionRepository.insertDfxTransaction\`/\`updateTransaction\` and
depends on \`AppStore.primaryAddress\` + \`apiConfig.asset.chainId\`.
Doable but adds repository mock plumbing for a method that's almost
entirely orchestration; will be its own focused PR.
- Buy / sell cubits — still held while #321 (dashboard buy actions) and
#332 (bitbox sign hardening) are open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 12 / 12 passing locally
- [ ] CI green
## Summary
Stage 13 of the coverage push. Covers four cubits in the sell and buy
flows whose source files are NOT touched by the currently-open #321
(dashboard buy actions, sell_page) or #332 (bitbox sign).

| Cubit under test | Test file | Cases |
| --- | --- | --- |
|
\`sell/cubits/sell_selected_bank_account/sell_selected_bank_account_cubit.dart\`
|
\`test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart\`
| 3 |
| \`sell/cubits/sell_balance/sell_balance_cubit.dart\` |
\`test/screens/sell/cubits/sell_balance_cubit_test.dart\` | 4 |
| \`sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart\` |
\`test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart\` | 6 |
| \`buy/cubits/buy_converter/buy_converter_cubit.dart\` |
\`test/screens/buy/cubits/buy_converter_cubit_test.dart\` | 9 |

## What each file covers
- **sell_selected_bank_account_cubit:** initial null,
\`selectBankAccount\` emits the provided account,
\`selectBankAccount(null)\` clears the selection.
- **sell_balance_cubit:** initial zero-balance derived from
\`AppStore.apiConfig.asset\` + \`primaryAddress\`; subscribes to
\`BalanceRepository.watchBalance\` on init; emits each pushed balance;
\`close()\` cancels the subscription.
- **sell_bank_accounts_cubit:** Success with DTO → BankAccount mapping
on init; LoadFailure on \`getBankAccounts\` throw; \`add()\` calls
\`createBankAccount\` + re-fetches; \`AddFailure\` preserves prior
accounts + carries error message; \`deactivate()\` calls
\`updateBankAccount(isActive=false)\` + re-fetches; \`UpdateFailure\`
preserves prior accounts.
- **buy_converter_cubit:** initial empty state; \`onFiatChanged\`
debounces (100ms) and writes the converted shares; multiple rapid
keystrokes only fire the LAST service call (pins the debounce contract);
service error keeps state stable; \`onSharesChanged\` matches the
output's fractional digits to the input (\`'5'\` → 2 digits, \`'5.000'\`
→ 3 digits — pins \`_fractionDigits\` behaviour); \`onCurrencyChanged\`
re-fetches in the new currency and emits both fields; currency still
flips on service error; \`close()\` cancels pending debounce timers so
no service call happens after close.

## Excluded (and why)
- \`sell_payment_info\`, \`sell_confirm\`, \`sell_converter\` (the
parallel cubit to buy_converter), \`buy_confirm\`, \`buy_payment_info\`
— each pulls in \`real_unit_buy_payment_info_service\` /
\`real_unit_sell_payment_info_service\` / signing flows that are likely
on PR #321's path. Held back to avoid review conflicts.
- \`sell_bitbox_cubit\`, \`hardware_connect_bitbox_cubit\` — both
directly touch BitBox; held while #332 is open.

## Test plan
- [x] \`flutter analyze\` on the four new files — clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
## Summary
Stage 14 of the coverage push. Adds 14 unit tests for the two
confirm/converter cubits where the source files are not on PR #321's or
#332's path.

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`buy/cubits/buy_confirm/buy_confirm_cubit.dart\` |
\`test/screens/buy/cubits/buy_confirm_cubit_test.dart\` | 5 |
| \`sell/cubits/sell_converter/sell_converter_cubit.dart\` |
\`test/screens/sell/cubits/sell_converter_cubit_test.dart\` | 9 |

## What each file covers
- **buy_confirm_cubit:** initial \`BuyConfirmInitial\`; happy path emits
\`BuyConfirmSuccess(reference)\`; \`ApiException(statusCode: 503)\` →
\`BuyConfirmFailure(BuyConfirmError.aktionariat)\` (pins the
Aktionariat-down branch); other \`ApiException\` →
\`BuyConfirmFailure(BuyConfirmError.unknown)\`; generic exception →
\`unknown\`.
- **sell_converter_cubit:** initial empty + CHF; \`onFiatChanged\`
debounces (100ms) and writes shares from \`getSellShares\`; respects an
explicit \`currency\` argument; debounce keeps only the last value (pins
the per-keystroke contract); state stable on service error;
\`onSharesChanged\` writes \`estimatedAmount\` with matching fractional
digits (\`'10.000'\` → 3 digits, \`'10'\` → 2 digits);
\`onCurrencyChanged\` calls \`getBuyPrice\` — NOT \`getSellPrice\` —
with the current \`sharesText\` (pins the intentional buy-side
estimation on currency switch); currency still flips even when
\`getBuyPrice\` throws; \`close()\` cancels pending debounce timers so
no service call after close.

## Notes
- The \`sell_converter\` "currency switch uses BUY price" pin documents
a non-obvious behaviour in the production code — leaving it untested
would let a future refactor silently switch to \`getSellPrice\` and lose
the no-fee preview.

## Excluded (still deferred)
- \`buy_payment_info_cubit\`, \`sell_payment_info_cubit\`,
\`sell_confirm_cubit\` — all touch
\`real_unit_buy_payment_info_service\` /
\`real_unit_sell_payment_info_service\` more deeply; #321 modifies these
services + their tests, so I'm holding them back to avoid review
conflicts.
- \`sell_bitbox_cubit\`, \`hardware_connect_bitbox_cubit\` —
BitBox-coupled; held while #332 is open.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 15 of the coverage push. Two more DFX services.

| Service | Test file | Cases |
| --- | --- | --- |
| \`lib/packages/service/dfx/dfx_widget_service.dart\` |
\`test/packages/service/dfx/dfx_widget_service_test.dart\` | 5 |
| \`lib/packages/service/dfx/real_unit_account_service.dart\` |
\`test/packages/service/dfx/real_unit_account_service_test.dart\` | 4 |

## What each file covers
- **dfx_widget_service:** \`wallet\` getter returns
\`appStore.wallet.currentAccount\`; \`walletAddress\` returns the EIP-55
hex of the primary address (pinned to the Hardhat #0 address);
\`isAvailable\` flips with the auth-token presence on the session cache.
- **real_unit_account_service:** \`getPortfolioHistory\` GETs
\`/v1/realunit/account/<eip55-hex>\` (path uses the wallet's primary
address); parses \`valueChf\`/\`valueEur\` scaled by 100; treats null
value as 0; returns \`[]\` on non-200 (does NOT throw — intentional).

## Notes
- \`real_unit_account_service\` uses
\`appStore.wallet.currentAccount.primaryAddress.address.hexEip55\` to
build the request path. The test plumbs a real \`SoftwareWallet\`
(Hardhat #0 mnemonic) through the AppStore mock so the EIP-55 hex is
real, not stubbed.

## Test plan
- [x] \`flutter analyze\` on the two new files — clean
- [x] \`flutter test\` — 9 / 9 passing locally
- [ ] CI green
…#340)

## Summary
Stage 16 of the coverage push.

| File | Cases (new) |
| --- | --- |
| \`buy/cubits/buy_payment_info/buy_payment_info_cubit.dart\` | 10 (new
file) |
| \`packages/service/balance_service.dart\` | 4 (appended to existing
test) |

## What each file covers
- **buy_payment_info_cubit:** initial \`BuyPaymentInfoInitial\`; happy
CHF path emits Success; amount below 100 CHF minimum →
\`MinAmountNotMetFailure\` and the service is NOT called; EUR minimum is
scaled by \`getChfToEurRate\` and ceil'd (e.g. 100 × 0.92 → 92); EUR
amount above scaled minimum proceeds to the service; empty amount string
is treated as 0; comma decimal separator normalised to dot (e.g.
\`'300,75'\` → 301); \`KycLevelRequiredException\` →
\`Failure(kycRequired, requiredLevel)\`;
\`RegistrationRequiredException\` → \`Failure(registrationRequired)\`;
generic exception → \`Failure(unknown)\`.
- **balance_service (4 new cases on top of the existing 2):** skips
saving when the response JSON has no \`balance\` field; catches a
non-numeric balance string and skips saving (production code does
\`BigInt.parse\` inside try/catch); \`getBalance\` delegates straight
through to \`BalanceRepository.getBalance\`; \`cancelSync\` is a safe
no-op when called before \`startSync\`.

## Notes
- \`buy_payment_info_cubit\` reads \`DFXPriceService.getChfToEurRate\`
only for the EUR branch, so the CHF tests don't need to stub it.
- The cubit's source file is NOT touched by PR #321 — only the
underlying service is — so this PR is conflict-free.

## Test plan
- [x] \`flutter analyze\` on the two changed files — clean
- [x] \`flutter test\` — 16 / 16 passing locally (12 in
buy_payment_info_cubit + 6 in balance_service incl. the 2 pre-existing)
- [ ] CI green
## Summary
Two sell-side cubits whose source files are NOT in PR #321's diff (only
the underlying services are).

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`sell/cubits/sell_confirm/sell_confirm_cubit.dart\` |
\`test/screens/sell/cubits/sell_confirm_cubit_test.dart\` | 3 |
| \`sell/cubits/sell_payment_info/sell_payment_info_cubit.dart\` |
\`test/screens/sell/cubits/sell_payment_info_cubit_test.dart\` | 12 |

## What each covers
- **sell_confirm_cubit:** initial \`SellConfirmInitial\`; happy path
passes through Loading and ends in Success (service mock suspends 10ms
so the broadcast-stream listener can observe Loading); service throw →
Failure carrying the error string.
- **sell_payment_info_cubit:**
- \`getPaymentInfo\` happy path emits Success with \`isBitbox=false\`
for a \`SoftwareWallet\`; \`isBitbox=true\` for a \`BitboxWallet\` stub.
- Exception mapping: \`KycLevelRequiredException\` →
\`Failure(kycRequired, requiredLevel)\`;
\`RegistrationRequiredException\` → \`Failure(registrationRequired)\`;
generic → \`Failure(unknown, message)\`.
- \`validateMinAmount\` (the parallel to buy_payment_info_cubit's min
check, but here it's a CHF-10 floor): below floor emits
\`MinAmountNotMet\`; above leaves the state untouched; EUR scaled by
\`getChfToEurRate\` (ceil); a prior \`MinAmountNotMet\` is cleared back
to \`Initial\` when the amount rises; comma decimal normalised to dot;
empty string treated as 0.

## Notes
- Both cubits use \`SellPaymentInfo\` which has 13 required fields.
Tests construct one real instance with stub addresses + a no-op
Eip7702Data so the value can be passed around without mocktail fallback
registration.
- For the BitBox-wallet test, a tiny \`_BitboxStubWallet extends
AWallet\` is used (only \`walletType\` is read; \`primaryAccount\` /
\`currentAccount\` throw to make the intent explicit).

## Test plan
- [x] \`flutter analyze\` on both new files — clean
- [x] \`flutter test\` — 15 / 15 passing locally
- [ ] CI green
)

## Summary
Stage 19 of the coverage push. Closes the previously-deferred
apiBasedSync gap on TransactionHistoryService — kept in a separate file
from \`fetchPendingTransactions\` (#336) to keep the repository mocks
focused on the sync flow.

| Method under test | Test file | Cases |
| --- | --- | --- |
| \`TransactionHistoryService.apiBasedSync\` |
\`test/packages/service/transaction_history_service_sync_test.dart\` | 8
|

## What it covers
- Non-200 on \`/v1/realunit/account/<addr>/history\` short-circuits: no
repository writes (the cubit's early return when AccountHistoryDto is
null).
- Entries whose \`transfer\` field is null are skipped.
- Inserts a plain \`Transaction\` when there is no matching DFX row.
- Inserts a DFX-enriched transaction (\`insertDfxTransaction\`) when
\`/v1/transaction\` has a row whose \`id\` is not null and whose
\`inputTxId\` matches the history hash.
- Existing transactions go through \`updateTransaction\` /
\`updateDfxTransaction\` instead of insert (governed by
\`existsTransaction\`).
- The DFX-row match also accepts \`outputTxId\` — both directions are
pinned.
- DFX rows whose \`id\` is null fall back to the plain-Transaction
insert path.

## Notes
- Both endpoints are fetched via \`Future.wait\`. Reading
\`request.url.path\` lets a single MockClient handler return different
bodies for the two paths.
- The test stubs \`existsTransaction\` to return false by default, then
flips it to true for the update-path tests.

## Test plan
- [x] \`flutter analyze\` on the new file — clean
- [x] \`flutter test\` — 8 / 8 passing locally
- [ ] CI green
## Summary
- always show dashboard buy/sell actions and the empty-state Buy
RealUnit button
- make Buy/Sell/BankAccount/Sell brokerbot requests auth-aware by using
lazy DFX auth and a one-time 401 refresh retry
- remove the now-dead HomeState.isFiatServiceAvailable setup path

## Why
The dashboard must not hide fiat entry points behind a transient
in-memory auth token. After making the entry points visible, the
downstream services also need to avoid sending Authorization: Bearer
null or stale tokens. Authenticated requests now call getAuthToken() at
first use and retry once with refreshAuthToken() on 401.

## Tests
- flutter analyze
- flutter test test/packages/service/dfx/dfx_auth_service_test.dart
test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart
test/screens/buy/buy_page_test.dart
test/screens/sell/sell_page_test.dart

---------

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
## Summary
Stage 21 of the coverage push. Closes the \`dfx_kyc_service\` surface
added in #343.

| Method | Cases |
| --- | --- |
| \`continueKyc\` | 2 |
| \`startStep\` | 2 |
| \`setData\` | 2 |
| \`getFinancialData\` | 3 |
| \`setFinancialData\` | 2 |

## What each method pins
- **continueKyc:** PUT \`/v2/kyc\` with the \`x-kyc-code\` header from
\`getUser().kyc.hash\`; parses \`KycSessionDto\`; \`ApiException\` on
non-2xx.
- **startStep:** GET \`/v2/kyc/<stepName.value>\` (the path encodes the
step enum's wire string); parses \`KycSessionDto\`; \`ApiException\` on
non-2xx.
- **setData:** PUTs the caller-provided session URL (NOT the host) with
the JSON body + \`x-kyc-code\` header; \`ApiException\` on non-2xx.
- **getFinancialData:** GET \`<url>?lang=<lang.code>\`; defaults the
language to \`Language.de\` (\`'de'\`); \`x-kyc-code\` header;
\`ApiException\` on non-2xx.
- **setFinancialData:** PUTs \`{ "responses": [...] }\` to the
caller-provided URL with \`x-kyc-code\` header; serialises each
\`KycFinancialResponse\` to \`{ key, value }\`; \`ApiException\` on
non-2xx.

## Conflict avoidance
Same as #343 — \`dfx_kyc_service.dart\` is NOT in PR #332's diff. The
service surface is stable on that branch.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 11 / 11 passing locally
- [ ] CI green
## Summary
Stage 20 of the coverage push. Covers four core methods of the
previously-deferred \`dfx_kyc_service\`.

| Method | Cases |
| --- | --- |
| \`getUser\` | 3 |
| \`updateUser\` | 3 |
| \`request2FaCode\` | 2 |
| \`verify2FaCode\` | 2 |

## What each method's tests pin
- **getUser:** GET \`/v2/user\` with Bearer JWT + parses \`UserDto\`;
201 also accepted (in addition to 200); throws \`ApiException\` on a
non-2xx non-401 (the 401-refresh-and-retry path is large enough to
deserve its own test, deferred).
- **updateUser:** PUT \`/v2/user\` with the JSON body; 201 accepted;
\`ApiException\` on non-2xx.
- **request2FaCode:** POST \`/v2/kyc/2fa?level=Strict\` with
\`x-kyc-code\` header set from \`getUser().kyc.hash\` (pins the kyc-hash
propagation); \`ApiException\` on non-2xx.
- **verify2FaCode:** POST \`/v2/kyc/2fa/verify\` with \`{ token: code
}\` body and the same \`x-kyc-code\` header; \`ApiException\` on
non-2xx.

## Conflict avoidance
PR #332 (bitbox sign + KYC routing) touches
\`lib/screens/kyc/cubits/kyc/kyc_cubit.dart\` and
\`real_unit_registration_service.dart\` — NOT this service.
\`dfx_kyc_service.dart\` itself is unchanged on that branch, so this PR
is conflict-free.

## Notes
- Tests use a tiny \`_StubWallet\` so \`DFXAuthService.wallet\` resolves
without plumbing a real \`SoftwareWallet\` through.
- The wire format for \`kyc.level\` is the numeric value (e.g. \`20\` →
\`KycLevel.level20\`), pinned in the test fixture so a future enum / DTO
refactor surfaces immediately.

## Excluded (deferred)
- \`getUser\`'s 401 → \`refreshAuthToken\` → retry path needs a stateful
MockClient + the auth-service signing flow; deserves its own focused
test.
- \`continueKyc\`, \`startStep\`, \`setData\`, \`getFinancialData\`,
\`setFinancialData\` — more elaborate bodies + headers; will follow up
if/when needed.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 10 / 10 passing locally
- [ ] CI green
Closes Acceptance Criterion #6 of #314 — _\"Documentation:
\`docs/testing.md\` explains when to use which tier, with examples.\"_

## Summary

A single page that captures everything we've learned across Phase 0
(#319) and Phase 1 Foundation (#320 / #321) about how to test
BitBox-touching code without re-deriving the rules every PR:

- **Tier matrix** (0–4) with hardware / CI status for each, so reviewers
can tell at a glance what a test does and doesn't prove
- **Decision tree** for picking a tier — \"if a Tier 0 test would have
to mock the very thing under test, drop down a tier\"
- **Tier 0 patterns** with concrete excerpts from real test files:
- Cubit / Bloc tests (\`bloc_test\` + \`mocktail.Mock\`) pointing at
\`test/screens/kyc/cubits/kyc/kyc_cubit_test.dart\`
- Widget tests (\`pumpApp\` + \`MockCubit\`) pointing at
\`test/screens/kyc/steps/kyc_email_page_test.dart\`
- Service + HTTP tests (\`http/testing\` \`MockClient\` +
\`_MockAppStore\`) pointing at
\`test/packages/service/dfx/dfx_bank_account_service_test.dart\`,
including the **\`setAuthToken('test-jwt')\` pre-seed** trick for
DFXAuthService-derived services (the gotcha that broke CI on #321)
- **Tier 1 patterns** for \`FakeBitboxCredentials\` with the
\`FakeBitboxBehavior\` matrix and the disconnect-flip-to-success
reconnect pattern, pointing at
\`test/integration/kyc_sign_flow_test.dart\`
- **Tiers 2–4** marked deferred with status pointers back to the
corresponding phase of #314
- **Mocktail gotchas** (\`Future\` + \`thenAnswer\`,
\`registerFallbackValue\`, private mocks)
- **Add-tests checklist** for PRs touching \`KycCubit\` /
\`Eip712Signer\` / \`DFXAuthService\` / \`BitboxCredentials\` /
\`bitbox_flutter\`

CONTRIBUTING.md's Testing section gets a one-line cross-link.

## Test plan

- [x] Doc compiles / renders (no broken intra-repo links in the
markdown)
- [x] Every code example matches a real file already on \`develop\`
- [x] \`flutter analyze\` — no new issues (4 pre-existing errors in
\`test/screens/home/home_bloc_test.dart\` are unrelated to this PR;
verified they exist on \`develop\` too)
…346)

## Summary
Stage 23 of the coverage push. Three small pure-Dart targets that round
out the config + widget-extension surface.

| File | Cases (new) |
| --- | --- |
| \`lib/packages/config/network_mode.dart\` | 3 (new file) |
| \`lib/packages/config/api_config.dart\` | 6 (appended to existing) |
| \`lib/widgets/mnemonic_field.dart\` (\`SeedStringExtension\`) | 5 (new
file) |

## What each file covers
- **network_mode:** mainnet / testnet getters + name;
\`NetworkMode.values\` has exactly 2 entries (a third would silently
bypass every \`switch (mode)\` call site).
- **api_config (extension):** testnet \`asset\` = \`realUnitTestAsset\`,
testnet ids = Sepolia; mainnet \`asset\` = \`realUnitAsset\`, mainnet
ids = Ethereum; \`buildUri\` produces https URIs; appends queryParams
when provided; omits the query string when null.
- **mnemonic_field SeedStringExtension:** splits a 12-word mnemonic
correctly; collapses tabs and multi-space runs; empty / whitespace-only
input → \`[]\`; trims leading/trailing whitespace; preserves word order.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 17 / 17 passing locally (incl. the 2
pre-existing api_config cases)
- [ ] CI green
)

## Summary
Stage 22 of the coverage push. Pure-Dart utility files.

| File | Cases |
| --- | --- |
| \`lib/packages/utils/svg_parser.dart\` | 5 |
| \`lib/packages/utils/device_info.dart\` | 5 |
| \`lib/packages/utils/default_assets.dart\` | 4 |

## What each file covers
- **svg_parser:** integer + decimal mm → px conversion at 96 DPI (≈
3.7795 px/mm), non-mm units pass through unchanged, every \`mm\`
occurrence is replaced (multiple per string), surrounding SVG markup is
preserved.
- **device_info:** iOS / Android / macOS / Windows+Linux / Fuchsia
matrix using \`debugDefaultTargetPlatformOverride\` (save + restore
around each test so a failure doesn't leak the override).
- **default_assets:** mainnet \`realUnitAsset\` and Sepolia
\`realUnitTestAsset\` pinned to their production / dev contract
addresses + decimals + symbols (a typo here would silently route the
entire fiat pipeline at the wrong contract). ETH and ZCHF asset-id
constants also pinned.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 24 of the coverage push. The \`SettingsRepository\` wraps
SharedPreferences and is one of the few storage-adjacent files that can
be tested directly via \`SharedPreferences.setMockInitialValues\`.

| Group | Cases |
| --- | --- |
| currentWalletId | 3 |
| language | 3 |
| currency | 3 |
| terms (terms + softwareTerms) | 3 |
| networkMode | 4 |

## What each group covers
- **currentWalletId:** null when not stored; \`saveCurrentWalletId\`
persists; \`removeCurrentWalletId\` clears.
- **language:** falls back to \`'en'\` for non-German system locales
(PlatformDispatcher locale in tests is en_US); returns stored value when
set; setter persists.
- **currency:** defaults to \`'CHF'\` when not stored; returns stored
value; setter persists.
- **terms:** both \`termsAccepted\` and \`softwareTermsAccepted\`
default to false; setters persist; setting one does not flip the other
(independent flags).
- **networkMode:** defaults to mainnet when no value AND when stored
value is unknown (\`firstWhere\`'s \`orElse\`); reads via the **enum
constructor argument name** (\`'Mainnet'\` / \`'Testnet'\`) — not the
Dart enum identifier (this trap surfaced during the tests because the
production code uses \`.name\` and the constructor arg shadows it);
setter persists.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 16 / 16 passing locally
- [ ] CI green
## Summary
Stage 25 of the coverage push. Three small pure-Dart targets.

| File | Cases |
| --- | --- |
| \`lib/packages/utils/asset_logo.dart\` | 7 |
| \`lib/models/blockchain.dart\` | 8 |
| \`lib/packages/utils/xfile_extension.dart\` | 7 |

## What each file pins
- **asset_logo:** mainnet ETH (\`chainId:1 + 0x0\`) → \`ETH.png\`;
mainnet RealUnit + Sepolia RealUnit → \`REALU.png\`; mixed-case address
still resolves REALU (production lowercases first); unknown
chain/address falls back to \`REALU.png\` (pinned — changing this would
silently render every unknown asset as ETH instead).
\`getChainImagePath\` for mainnet + Sepolia.
- **Blockchain:** \`Blockchain.values\` has exactly 2 entries; chainId /
name / nativeSymbol for both; \`getFromChainId\` resolves the known ids
and throws \`StateError\` on unknown; \`nativeAsset\` shape (address
\`0x0\`, decimals 18).
- **xfile_extension:** \`toBase64DataUri\` uses an explicit \`mimeType\`
when present; falls back to extension-based guessing for \`.png\` /
\`.jpg\` / \`.JPEG\` (case-insensitive) / \`.pdf\`; defaults to
\`application/octet-stream\` for unknown extensions; the base64 segment
encodes the raw file bytes (verified by round-tripping a UTF-8 payload).

## Notes
- \`xfile_extension\` tests write a real \`XFile\` against a per-test
temp directory so the production code's \`readAsBytes\` path runs
unchanged. No platform-channel plumbing needed.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 22 / 22 passing locally
- [ ] CI green
## Summary
Stage 26 of the coverage push. Pure-Dart model classes.

| Model | Cases |
| --- | --- |
| \`Asset\` | 5 |
| \`Balance\` | 5 |
| \`Transaction\` | 3 |
| \`TransactionTypes\` enum | 1 |

## What each model pins
- **Asset:** \`id\` derived from \`fastHash(chainId:address)\`;
\`Asset.getId\` static matches the instance getter; different chains /
addresses produce different ids; \`id\` depends only on chain+address,
not on metadata (\`name\` / \`symbol\` / \`decimals\` don't affect
identity — important for repo de-duplication).
- **Balance:** \`id\` from \`fastHash(wallet:chain:contract)\`;
\`balance\` amount is mutable; two Balances with the same identity tuple
are \`==\` regardless of amount (pinned so a stream-emitted update with
a new amount can still match an existing map key); different chain /
wallet differs.
- **Transaction:** \`isOutbound\` true when sender matches wallet
(EIP-55 normalised); false when sender differs; normalises hex-digit
case via \`fromHex\` / \`hexEip55\` on both sides (NOT case-insensitive
on the \`0x\` prefix — that breaks fromHex; the test pins the actual
contract).
- **TransactionTypes:** values has exactly 5 entries (catches an
accidental addition that would silently break the rendering switch).

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 14 / 14 passing locally
- [ ] CI green
## Summary
Stage 27 of the coverage push. Wire-format DTOs that were missing direct
tests — important to pin because they encode the contract with the DFX
backend.

| DTO | Cases |
| --- | --- |
| \`PriceStep.fromJson\` | 2 |
| \`BroadcastTransactionRequestDto.toJson\` | 1 |
| \`BroadcastTransactionResponseDto.fromJson\` | 1 |
| \`RealUnitUnsignedTransactionsRequestDto.fromJson\` | 1 |
| \`RealUnitSellConfirmDto.toJson\` | 4 |
| \`Eip7702DelegationDto.toJson\` | 1 |

## What's pinned
- **PriceStep:** full wire shape (\`source\` / \`from\` / \`to\` /
\`price\` / \`timestamp\`); integer \`price\` widens to double (matches
the \`num\` cast in production).
- **Broadcast req/resp:** request serialises the four signature parts
(\`unsignedTx\`, \`r\`, \`s\`, \`v\`); response extracts \`txHash\`.
- **Unsigned-transactions request:** parses \`swap\` + \`deposit\`.
- **SellConfirmDto:** the conditional-key serialisation (4 branches —
txHash-only, eip7702-only, both, neither/empty). This is load-bearing on
the wire and a regression here would silently send empty confirms.
- **Eip7702DelegationDto:** round-trips all five fields.

## Test plan
- [x] \`flutter analyze\` clean
- [x] \`flutter test\` — 10 / 10 passing locally
- [ ] CI green
TaprootFreak and others added 30 commits May 26, 2026 16:50
Collection PR for follow-ups identified during the #541 review session.
Held as a **Draft** while commits accumulate; flipped to ready + merged
at the end.

### Likely contents (subject to scope decisions)
- `lib/styles/text_styles.dart` — hardcode `fontFamily` directly in
`_Body.base/.sm/.xs` + `_Header.h1/.h2/.h4` (root-cause for the latent
`appBarTheme.titleTextStyle` Ahem-rendering bug; supersedes the
per-theme `copyWith(fontFamily: …)` pin added in #562)
- `test/integration/wallet_creation_bitbox_test.dart:194-195` — drop the
tautological `appStore.wallet = created; verify(...).called(1);` or
replace with a real contract pin
- `test/integration/connect_bitbox_flow_test.dart:249,285` — align
wall-clock `Future.delayed(fastObserverInterval * 4)` with the
`fakeAsync`+`async.elapse` pattern used in
`bitbox_reconnect_recovery_test.dart`
- `test/goldens/screens/legal/legal_document_golden_test.dart` — replace
inline `_termsMarkdownStub` (1:1 copy of production terms) with a
synthetic markdown fixture under `test/fixtures/`
- Investigate why the dfx01 runner produces ±10–50 byte encoder drift on
unrelated golden PNGs during baseline refresh (Skia version pin, build
cache, font cache)

### Out of scope
- Anything that should go to a dedicated PR (e.g. a real new feature)
- #325 release develop → main

### Notes
Empty seed commit kept in history so the PR existed before the first
real change landed.

---------

Co-authored-by: Blume1977 <jana.ruettimann@dfx.swiss>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Empty seed commit so a draft PR can exist before the actual follow-ups
land. Real changes (further goldens state-coverage, BitBox-aware variant
pinning, additional handbook slots, etc.) get pushed on top of this
branch and are visible incrementally in the PR diff.
…586)

## Summary

Both `TransactionIssue` and `LimitRequest` options in the in-app support
ticket type picker silently fail with HTTP 400 on submit because the app
never sends the API-required nested DTOs (`transaction` /
`limitRequest`). The reproduction in the field is `RealUnitApiException:
limitRequest should not be empty (statusCode: 400)` for LimitRequest;
the TransactionIssue equivalent has not surfaced because realunit-app
users typically file transaction issues via services.dfx.swiss instead.

This PR hides both options from the picker until the proper forms are
implemented. The enum values, i18n keys (`supportTransactionIssue`,
`supportLimitRequest`), icons, and the cubit's `_getTicketName` mapping
are intentionally left in place so re-enabling is a one-tuple revert per
type once the structured input UI (dropdowns, transaction picker,
document upload) lands.

## Why not implement the forms now

- `LimitRequest` needs three dropdowns (`limit` / `investmentDate` /
`fundOrigin`), an optional `fundOriginText` field mapped from message, a
KYC ≥ 50 gate, and a mandatory document upload (PNG / JPEG / PDF).
Requires a new `file_picker` dependency for PDF support.
- `TransactionIssue` needs a transaction picker (list of the user's
recent transactions via `GET /v1/transactions`) plus a reason dropdown
filtered to `[FundsNotReceived, TransactionMissing, Other]`.
- Both are independent feature PRs of meaningful scope; hiding now
removes the user-facing failure immediately.

## Scope

- `lib/screens/support/subpages/support_create_ticket_page.dart` —
remove two tuples from the `items:` array of
`TagSelection<SupportIssueType>`.
- No API, model, cubit, service, or i18n changes.

## Test plan

- [x] `flutter analyze` → `No issues found! (ran in 4.7s)`
- [x] `flutter test test/screens/support/` → 62 / 62 passed
- [ ] Manual smoke: open `Neues Ticket erstellen`, confirm only
`Allgemeines Anliegen`, `Fehlerbericht`, `KYC-Problem` are selectable.
- [ ] Manual smoke: submit each of the three remaining types with a
short message and verify a ticket is created (no 400).
Re-opens [#584](#584)
(closed because its base \`chore/post-541-followups\` was deleted after
the collection-merge
[#571](#571)).
Cherry-picked Jana's original commit (\`517170a\`) onto the new
collection branch \`chore/post-580-followups\`.

## Summary

The "Support kontaktieren" tile under Settings → Kontakt must always be
visible — including pre-signin onboarding flows where the user has not
yet registered an email. Render it unconditionally and drop the
surrounding cubit/state machinery that only existed to gate this single
tile.

## Pair PR

Pairs with
[DFXswiss/api#3761](DFXswiss/api#3761) (drops
\`UserCapabilitiesDto.supportAvailable\`).

**Merge order is unconstrained — both PRs are independent-safe:**

| Order | Backend sends | App reads | Crash? |
|---|---|---|---|
| App-PR alone merged | \`supportAvailable: true/false\` still in JSON |
Field no longer read in \`fromJson\` — Dart \`Map<String,dynamic>\`
silently ignores unknown keys | No |
| API-PR alone merged | Field gone from JSON | Old app code reads
\`json['supportAvailable'] as bool? ?? false\` → \`false\` → tile stays
hidden (existing bug persists) | No |
| Both merged | Backend stops sending, app stops reading | — | No |

The DTO uses \`as bool? ?? false\` (nullable cast with default fallback)
for every capability flag, so neither side is brittle to the other's
deploy timing. Earlier "Merge order: API first" claim was inaccurate.

## Review history

Audited via subagent during the #584 cycle (clean, no MAJORs). Main-repo
mirror was needed for Visual Regression on the dfx01 self-hosted runner
(same Fork-PR pattern as
[#585](#585)).

Credit: code-diff by Jana Rüttimann (\`Blume1977\`).

---------

Co-authored-by: Blume1977 <jana.ruettimann@dfx.swiss>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…set (#589)

## Summary

Mirror the API-side `@IsSwissPaymentText()` validator
(DFXswiss/api#3766) in the four user flows that write into `user_data`:

- KYC registration — personal step (`firstName`, `lastName`)
- KYC registration — address step (`street`, `houseNumber`,
`postalCode`, `city`)
- Settings → edit name (`firstName`, `lastName`)
- Settings → edit address (`street`, `houseNumber`, `postalCode`,
`city`)

Users now get immediate field-level feedback in the form if they enter a
character outside the SIX SIG IG QR-Bill v2.3 permitted character set,
instead of submitting and waiting for a generic 400 from the backend.

## Why

DFX business decision: only characters valid in Swiss payment systems
are supported end-to-end. Pairs with the umlaut-preservation fix in
DFXswiss/api#3766 — now that diacritics survive storage and PDF
rendering, we also lock the input surface so CJK / Cyrillic / Arabic /
emoji never enter the system.

## Character set

```
[\x20-\x7E] (printable ASCII)
+ À Á Â Ä Ç È É Ê Ë Ì Í Î Ï Ñ Ò Ó Ô Ö Ù Ú Û Ü Ý ß
+ à á â ä ç è é ê ë ì í î ï ñ ò ó ô ö ù ú û ü ý
+ \n (line feed)
```

Covers German, French, Italian, Romansh. Regex is byte-for-byte aligned
with the API regex (`Config.formats.swissPaymentText`) so any input the
client accepts the backend also accepts.

## Changes

- `lib/packages/utils/swiss_payment_text.dart`: new
`isSwissPaymentText(String?)` helper (empty/null = valid).
- Four screen files: add the helper call to existing `validator:`
callbacks; missing validator on house-number in settings edit-address
page added.
- `assets/languages/strings_en.arb`, `strings_de.arb`: new
`swissPaymentTextInvalid` translation key, inserted in alphabetical
order.
- Generated `lib/generated/i18n.dart` is gitignored — built by `dart run
tool/generate_localization.dart` after pulling.

## Test plan

- [ ] `dart run tool/generate_localization.dart` runs without errors
after checkout
- [ ] `flutter analyze` — passes (verified locally, no new issues)
- [ ] Type `Rüttimann` / `Münchwilen` / `Genève` / `Saint-Légier` in any
of the four flows → no error, form accepts
- [ ] Type `王小明` / `Иван` / `日本語` / an emoji → field shows the
Swiss-payment-text error message
- [ ] Submit a valid form → API call succeeds (i.e. client and server
regex match)
- [ ] German UI shows German error message; English UI shows English
message
- [ ] `flutter test` — pre-existing golden failures unchanged (verified
locally — same 88 failures on clean develop)
Add the missing test coverage for `lib/packages/utils/swiss_payment_text.dart`
(introduced in #589) so the regex character set is locked in against
regressions when the API-side `Config.formats.swissPaymentText` drifts.

33 cases across 8 groups:

- empty / null → valid (matches the docstring contract: callers chain a
  non-empty check)
- printable ASCII (0x20–0x7E): digits, punctuation, full 95-char band
- uppercase + lowercase Latin diacritics for CH / DE / FR / IT
- real Swiss names + addresses (Rüttimann, Münchwilen, Genève,
  Saint-Légier, D'Hauterive, François)
- non-Latin scripts rejected (CJK, Cyrillic, Arabic, Hebrew, emoji)
- mixed-script attacks (Latin word with a Cyrillic homoglyph е inside)
- whitespace: newline accepted (multi-line memos), tab + CR rejected
- uncommon Latin diacritics rejected (Polish ąć, Portuguese ã,
  Norwegian øå, French ligature œ, French Ÿ) — explicitly documents
  what is NOT in the Swiss payment set so the regex is hand-verifiable

Run: `flutter test --no-pub test/packages/utils/swiss_payment_text_test.dart`
→ 33/33 pass.
## Summary
- Handbook (`docs/handbook/de/index.html:1088-1090`) behauptet, die
Wallet-Sicherung-Kachel ist bei BitBox-Wallet ausgeblendet
- Bestehender Golden deckt nur die Software-Wallet-Variante ab —
Invertierung der Bedingung in `settings_page.dart:100` würde nicht
auffallen
- Neuer `goldenTest` „bitbox wallet open hides the Wallet-Sicherung
tile" sichert die hidden-tile-Logik ab
- `MockBitboxWallet` in `test/helper/golden_mocks.dart` ergänzt (analog
zu `MockSoftwareWallet`)

Schließt Reviewer-Finding #2 aus dem #582-Review.

## Test plan
- [ ] CI grün (Visual Regression, Analyze & Test, Coverage Floor Gate,
Handbook Build Check)
- [ ] Goldens via `golden-regenerate.yaml` auf dfx01 generiert
(erwartet: 1 neuer PNG `settings_page_bitbox.png`)
- [ ] Manuell verifiziert: PNG zeigt Settings-Page ohne
Wallet-Sicherung-Tile

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…592)

Follow-up to the BitBox-Goldens deep-dive done after
[#580](#580). Of the 8
candidate BitBox connection sub-states, 3 are pixel-distinct UI patterns
worth pinning; the other 5 are either identical to the baseline,
near-variants of the top-3, or have no UI at all.

## Added (top-3 by ROI)

| File | State | Why pinned |
|---|---|---|
| `connect_bitbox_page_connecting.png` | `BitboxConnecting` | Only
spinner-only state with neither confirm nor cancel — distinct
\`ConnectContent\` branch |
| `connect_bitbox_page_check_hash.png` | `BitboxCheckHash` | Highest
pixel-drift risk: alpha-blended pairing-code pill with custom
BoxDecoration |
| `connect_bitbox_page_signature_failed.png` | `BitboxSignatureFailed` |
Only state with two override-labelled buttons (Retry + Continue anyway)
|

## Out of scope (deliberately)

| State | Reason |
|---|---|
| `BitboxFound` | Renders identical to \`BitboxNotConnected\` — view's
switch has no case, falls to \`_ =>\` default |
| `BitboxPairing` | Spinner subtitle variant of \`BitboxConnecting\` —
near-duplicate |
| `BitboxCapturingSignature` | Title-only variant of \`BitboxPairing\` |
| `BitboxConnected` | Button + title variant of the connected SVG branch
— close to \`SignatureFailed\` |
| `BitboxFinishSetup` | Emits no UI, only a BlocListener
\`onFinish(wallet)\` callback |

## Coverage gap closed

The view file
\`lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart\` has
three orthogonal branches in its state-switch (spinner-only, hash-pill,
two-button-with-override-labels). Before this PR, only the default \"no
UI ceremony\" branch (\`BitboxNotConnected\`) was pixel-locked.
Cubit-tests (16 in \`connect_bitbox_cubit_test.dart\`) cover the
state-machine logic, view-tests cover button wiring — but pixel-layout
regression of these three branches was structurally unguarded.

## Mock setup

Lifted verbatim from
\`test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart\`:
\`_FakeBitboxDevice extends Fake\`, \`_MockBitboxWallet extends Mock\`.
No new helper file, no new pubspec entries.

## Pending CI dance

- Bot golden-regen workflow will commit the three new baselines on the
dfx01 self-hosted runner.
- Empty re-trigger commit needed afterwards per the GITHUB_TOKEN gotcha
documented in \`docs/visual-regression-tests.md\`.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Rolling collection branch for follow-ups after
[#580](#580) (goldens max
coverage + handbook 52 slots) merged into develop via
[#571](#571). Same pattern
as the previous `chore/post-541-followups` collection.

## Mechanics

- Feature PRs target this branch as their base. Each lands as a separate
PR for review history.
- This collection PR aggregates them; when stable, it's merged into
develop in one go.
- Initial commit is intentionally empty — actual changes land on top.

## Known follow-up topics

Tracked from the #580 audit + day-2 fixes:

- BitBox-Wallet-Variante des Settings-Goldens (`settings_page_bitbox`) —
die HTML-Erklärung im Handbook erwähnt das Hiding der
Wallet-Sicherung-Tile bei BitBox, kein Golden pinnt es derzeit.
- BitBox-Connection-State-Goldens — aktuell nur `BitboxNotConnected` ist
als Golden. Weitere 8 States (Found, Connecting, CheckHash, Pairing,
CapturingSignature, SignatureFailed, Connected, FinishSetup) sind
theoretisch render­bar via Mock-Wallet.
- Eventuelle Locale-`en`-Goldens für die Pages, wo lange deutsche
Strings Layout-relevant sind (Buy/Sell Banner).

---------

Co-authored-by: Blume1977 <jana.ruettimann@dfx.swiss>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…red-wording

feat(i18n): broaden purchase pre-flight message wording
…#597)

## Symptom (v1.0.67, reproduced live)

Open Buy page → quickly delete the prefilled \`300\` → type \`4600\` →
**Result-Feld zeigt 321** statt der korrekten 3216 REALU. Erneutes
Löschen + Tippen von \`4600\` zeigt 3216.

\`floor(460 / 1.43) = 321\` — der Smoking Gun: die App hat das Resultat
einer API-Anfrage für \`amount=460\` angezeigt, obwohl der Controller
\`4600\` enthält.

## Root cause

\`_fiatDebounce?.cancel()\` storniert nur **noch nicht gefeuerte**
Timer. Sobald ein Timer-Body läuft, schickt er seine API-Anfrage raus
und emittet das Resultat bei Rückkehr — unabhängig davon, was der User
inzwischen tippt.

Konkrete Sequenz für das Symptom:

\`\`\`
T=0    '4'   → emit fiatText:'4', Timer→100ms
T=20   '6'   → cancel, Timer→120ms
T=40   '0'   → cancel, Timer→140ms        ← fiatText='460' jetzt
T=140  Timer für '460' feuert: emit loading:true, API_460 startet ─┐
T=160 '0' → cancel (zu spät!), Timer→260ms │ in flight
T=260  Timer für '4600' feuert: API_4600 startet ────┐            │
T=380  API_4600 antwortet → shares:3216 → emit ✓     │            │
T=420  API_460 antwortet später → shares:321 → emit ← ÜBERSCHREIBT
\`\`\`

Zwei in-flight Requests, last-write-wins, der ältere gewinnt
nicht-deterministisch je nach Latenz.

## Fix

Monotoner Counter \`_seq\` im Cubit. Jede User-Methode
(\`onFiatChanged\`, \`onSharesChanged\`, \`onCurrencyChanged\`)
inkrementiert
ihn synchron und übergibt den Wert via Closure-Capture an den
async-Body. Der Body prüft vor jedem \`emit\` (vor und nach dem
\`await\`), ob \`_seq\` weitergewandert ist, und droppt sich
stillschweigend
falls ja.

\`onCurrencyChanged\` flippt \`state.currency\` zusätzlich **vor** dem
\`await\` — der Picker reagiert sofort, auch wenn die nachfolgende
Konversion vom Seq-Guard verworfen wird. Existing Test
\`'onCurrencyChanged still flips currency even on service error'\`
bleibt grün.

## Tests

**5 neue Race-Tests** (3 buy, 2 sell), die in-flight Responses out of
order completen und pinnen dass der Seq-Guard die alten droppt — also
direkt das Symptom aus v1.0.67 pinnen.

**Bestehende Tests** (28 cubit, 180 buy/sell breit) bleiben grün ohne
Anpassungen.

## Verifiziert lokal

- \`flutter analyze lib/screens/buy/cubits/buy_converter/
lib/screens/sell/cubits/sell_converter/ test/screens/buy/cubits/
test/screens/sell/cubits/\` → clean
- \`flutter test test/screens/buy/cubits/buy_converter_cubit_test.dart
test/screens/sell/cubits/sell_converter_cubit_test.dart\` → 28/28
- \`flutter test test/screens/buy/ test/screens/sell/
test/screens/buy_sell_converter_confirm_states_test.dart\` → 180/180

## Was bewusst NICHT in dieser PR ist

- **D1** Stale \`sharesText\` bei API-Fehler (verschieden vom Race;
separater Pfad)
- **D2** Komma-Sanitization im Brokerbot-Service (PaymentInfo macht es
schon, Converter nicht — separate PR)
- **D3** Loading-Indikator + Error-Surface in der UI (Welle 2)
- **Eager Controller-Init / focus-aware Sync** (UX-PR, ändert Behavior)

Diese sind reale Defekte aber **nicht Ursache** des reproduzierten
Symptoms. Sie verschmutzen den Race-Fix nur und werden separat
adressiert.
## Summary

The support-chat bubble shipped with a broken sender check —
`isFromSupport => author == null` — that never matched, because the
backend always sends a non-null `author` string:

| Sender | `author` value (from `support-message.entity.ts`) |
|---|---|
| Customer | `'Customer'` (the `CustomerAuthor` constant) |
| Bot | `'AutoResponder'` |
| Support agent | clerk name from the `supportClerks` setting (fallback
`'Support'`) |

As a result every message — including the auto-responder's "Hi Jana, …"
template — was rendered as a user message: right-aligned, in
`realUnitBlue`. The screenshot below the PR description shows the visual
collision.

## Changes

- `support_message.dart`: introduce `customerAuthor` constant (mirroring
the
  API), expose `isFromCustomer` getter, redefine `isFromSupport` as its
inverse. Anything that is not the literal `'Customer'` string (agent
name,
  `AutoResponder`, or a defensive `null`) now counts as support.
- `support_chat_message_bubble.dart`: switch the bubble's alignment /
colour
  branch on `isFromCustomer`. Render a small `Support` label above the
bubble for non-customer messages so the user can tell agent/bot replies
  apart from their own.
- `assets/languages/strings_{de,en}.arb`: add `supportChatSupportLabel`
(alphabetically inserted between `supportChat` and
`supportCreateTicket`).
- Model + widget tests rewritten with realistic wire values
  (`'Customer'`, `'Robin'`, `'AutoResponder'`) — the previous fixtures
  (`'alice'`, `'user-1'`, `null`) hid the bug.

## Test plan

- [x] `flutter analyze` clean on touched files
- [x] `flutter test test/screens/support
test/packages/service/dfx/models/support` — 73 tests pass
- [x] `dart run tool/generate_localization.dart` regenerates
`lib/generated/i18n.dart` (gitignored)
- [ ] Manual: open a ticket in the app, send a message, confirm the
      AutoResponder reply now lands on the left with the `Support` label
- [ ] Manual: confirm a real agent reply renders identically (left,
grey,
      `Support` label)
…lessons (#593)

Companion to
[DFXswiss/api#3773](DFXswiss/api#3773). The
David-review back-and-forth on the Wave-3 reset produced a concrete set
of rules that should bind every future capability consumption in this
repo — this PR writes them down so the next contributor doesn't have to
rediscover them.

## Three docs updated

### 1. \`CONTRIBUTING.md\` — new sub-section "Consuming API capabilities
— eight rules"

Lives inside the existing "API as Decision Authority — CRITICAL"
section. Covers:

1. Read the capability shape, don't reconstruct it
2. Tile/button visibility for discoverable actions is unconditional
3. Map prerequisite types to UI components, not to business rules
4. Legacy backend tolerance — capability optional, sane fallback
5. No reactive 400-handling for what a capability could pre-tell
6. Pair-PR discipline
7. Tests pin the contract, not the implementation
8. Push back on capability shape that's over-engineered

Cross-references the API-side mirror in
[\`DFXswiss/api:CONTRIBUTING.md\`](https://github.com/DFXswiss/api/blob/develop/CONTRIBUTING.md).

### 2. \`docs/api-authority-plan.md\` — Wave-3 lessons-learned block

Documents the **six-PR** history of V9 (a single capability flag):

| PR | Direction | Outcome |
|---|---|---|
| [api#3733](DFXswiss/api#3733) | API:
\`+supportAvailable: bool\` | merged |
| [app#497](#497) | App:
consume \`supportAvailable\` bool | merged |
| [app#588](#588) | App:
unconditional Support tile | merged |
| [api#3761](DFXswiss/api#3761) | API:
\`-supportAvailable: bool\` | merged |
| [api#3767](DFXswiss/api#3767) | API:
ActionCapability tree (4 DTOs, 170 LOC) | **closed without merge** |
| [api#3772](DFXswiss/api#3772) | API:
\`createSupportTicket: { available, missingPrerequisite? }\` (91 LOC) |
merged |

Plus three "what we'd do differently" points and forward guidance for
Waves 4 and 5.

### 3. \`docs/api-authority-audit.md\` — V9 closed

Marked as closed by api#3772 with a link back to the lessons-learned
section (because the linear "closed by W3" mapping in the audit table
doesn't capture the actual non-linear history).

## Why now

Cyrill asked for these rules to be prominently documented in both repos
and in cross-repo working notes so the pattern doesn't drift on the next
capability. The API side gets the mirror PR
([DFXswiss/api#3773](DFXswiss/api#3773)).

## Verification

- Pure documentation — no code changes
- Markdown renders cleanly (verified)
- Cross-references resolve

## Companion PRs

- API rules:
[DFXswiss/api#3773](DFXswiss/api#3773)
- App pair-PR consuming the first capability following this pattern
(\`createSupportTicket\`): opening shortly on
\`refactor/consume-support-capability\`.
)

Companion app PR to
[DFXswiss/api#3772](DFXswiss/api#3772) (merged
2026-05-26). **Closes V9** in
[\`docs/api-authority-audit.md\`](docs/api-authority-audit.md).

The Support tile now reads the new
\`user.capabilities.createSupportTicket\` field for its tap decision. No
more local \`mail != null\` reconstruction; the rule lives on the
backend, the app maps a typed enum value to a UI step.

## Architecture

Per the eight consumer rules in [\`CONTRIBUTING.md\`](CONTRIBUTING.md) →
"Consuming API capabilities — eight rules" (documented in
[#593](#593)):

| State | Tap action |
|---|---|
| \`capability == null\` (legacy backend pre-#3772) | Direct push to
Support — API is the authority |
| \`capability.available == true\` | Direct push to Support |
| \`capability.available == false, missingPrerequisite == email\` | Push
email capture page; on \`pop(true)\` re-init the cubit and push Support
if the refreshed capability is now available (or null — symmetric to
branch 1) |
| \`missingPrerequisite == unknown\` or \`null\` | Defensive direct push
— let the API render the error |

\`MissingPrerequisite\` is an **open enum** with \`email\` +
\`unknown\`. Additive backend values degrade to \`unknown\` so a future
prerequisite type never breaks \`/v2/user\` parsing for unrelated
callers (KYC, settings, etc.).

## What changes

### \`lib/\`
- \`packages/service/dfx/models/user/dto/user_dto.dart\` —
\`UserCapabilitiesDto.createSupportTicket\` optional field +
\`CreateSupportTicketCapabilityDto\` + open enum
\`MissingPrerequisite\`.
- \`screens/settings_contact/settings_contact_page.dart\` —
BlocProvider-wrapped; Support tile \`onTap\` dispatches through
\`_onSupportTap\` (4 branches above). Tile layout unchanged.
- \`screens/settings_contact/cubit/...\` — new \`SettingsContactCubit\`
+ state (\`part of\` pattern, States extend Equatable).
- \`screens/support/cubits/support_email_capture/...\` — new cubit +
state for the standalone email capture flow.
- \`screens/support/subpages/support_email_capture_page.dart\` —
standalone page (no KYC coupling) calling
\`RealUnitRegistrationService.registerEmail\`. \`mergeRequested\` status
surfaces a dedicated message — the multi-step verification flow is
deliberately not dragged into this minimal page.
- \`setup/routing/routes/support_routes.dart\` +
\`setup/routing/router_config.dart\` — new
\`SupportRoutes.emailCapture\` under \`/support/email\`.
- \`assets/languages/strings_{en,de}.arb\` — 4 new keys (alphabetically
sorted, both languages).
- \`lib/generated/i18n.dart\` — regenerated via \`dart run
tool/generate_localization.dart\`.

### \`test/\`

| File | Tests |
|---|---|
| \`packages/service/dfx/models/user/dto/user_dto_test.dart\` | +14
cases for createSupportTicket parsing, incl. \`unknown\` degradation and
JSON-null handling |
| \`screens/settings_contact/cubit/settings_contact_cubit_test.dart\` |
6 cases |
| \`screens/settings_contact/cubit/settings_contact_state_test.dart\` |
8 cases |
| \`screens/settings_contact/settings_contact_page_test.dart\` | 15
widget tests covering tile visibility + 11 routing branches incl.
\`unknown\` and pop(null\|false) |
|
\`screens/support/cubits/support_email_capture/support_email_capture_cubit_test.dart\`
| 5 cases (success, mergeRequested, ApiException, generic throw) |
|
\`screens/support/cubits/support_email_capture/support_email_capture_state_test.dart\`
| 7 Equatable cases |
| \`screens/support/subpages/support_email_capture_page_test.dart\` | 9
widget tests |
| \`goldens/screens/support/support_email_capture_golden_test.dart\` | 2
goldens (default + submitting) |
| \`goldens/screens/settings_contact/settings_contact_golden_test.dart\`
| Re-baselined for BlocProvider wrap; visual surface unchanged |

## Local verification

- \`dart format\` clean on all touched files
- \`dart analyze lib/ test/\` — no issues found
- \`flutter test\` on touched scope → **140/140 green**

## Review history

Implemented + reviewed via internal subagent loop, three iterations:
1. First implementation off \`develop\` → had to rebase onto
\`chore/post-580-followups\` (PR #588 base mismatch).
2. Reviewer found 2 SHOULD-FIX (Branch-1 asymmetry with \`?? false\`
violating the no-fallback rule; \`MissingPrerequisite.fromString\` throw
breaking unrelated \`/v2/user\` callers on additive backend changes).
Both addressed: explicit null-check symmetry, open-enum \`unknown\`
degradation.
3. Final reviewer pass found a \`dart format\` issue on the enum block —
fixed; added an explicit \`unknown\`-routing widget test as a
NICE-TO-HAVE.

## Targeting \`chore/post-580-followups\`

Per request — this is a post-#580 follow-up that consumes a new API
capability rather than introducing one in isolation. PR base set
accordingly.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary

Integration branch bundling the RealUnit registration UX improvements so
they can land on `develop` as a single coordinated change. Individual
feature PRs target this branch as their base; once they have all merged
here, this PR is reviewed end-to-end and merged into `develop`.

## Member PRs

- **[#599](#599) —
feat(i18n): broaden purchase pre-flight message wording**
Renames the buy-screen banner from "Registrierung erforderlich" →
"Zusätzliche Angaben erforderlich" so KYC-completed users aren't told
they need to register again.
- **[#600](#600) —
feat(realunit): pre-fill registration form from wallet status**
Pre-populates the Aktionariat registration wizard from `GET
/v1/realunit/wallet/status`, replacing the empty-controllers experience
that today forces users to retype data the backend already holds. Pairs
with [DFXswiss/api#3782](DFXswiss/api#3782).

## Why bundle

Both PRs touch the same screen (RealUnit registration), share goldens,
and only deliver their full UX value together — the new wording
communicates the right expectation, and the pre-fill makes that
expectation accurate. Reviewing them as one end-to-end change at the
integration step prevents partial-merge regressions and keeps the
goldens in sync.

## Workflow

1. Open each member PR against `integration/realunit-registration`
2. Merge member PRs into this branch as they're approved
3. Review this PR end-to-end against `develop` (full diff = combined
member content)
4. Merge into `develop`

Empty seed commit will disappear once the member PRs merge in and the
branch diverges with real content.

---------

Co-authored-by: Blume1977 <jana.ruettimann@dfx.swiss>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…wallet-status

feat(realunit): pre-fill registration form from wallet status
## Summary

Adds an intermediate `staging` lane between feature branches and
`develop`, mirroring the existing `develop -> main` automation.

After this lands:

1. Feature PRs target `staging` (not `develop`).
2. A merge into `staging` triggers `auto-staging-pr.yaml`, which opens a
`staging -> develop` PR — analogous to `auto-release-pr.yaml`'s `develop
-> main` flow.
3. Merging that PR into `develop` keeps the existing `develop -> main`
release flow intact.

## What changes

- **New** `.github/workflows/auto-staging-pr.yaml` — copy of
`auto-release-pr.yaml`, retargeted to `staging -> develop`. Opens a
single open PR with a commit-count + checklist, idempotent.
- **Modified** `.github/workflows/pull-request.yaml` — adds `staging` to
the `push:` trigger so post-merge verification fires the moment a
feature merge lands on `staging`. The existing `pull_request:
branches-ignore: [main]` already covers PRs targeting `staging`, so
feature → staging PRs already run the build with no further trigger
change.

## Workflows already covering staging without changes

| File | Why it already covers staging |
|---|---|
| `bitbox-simulator.yml` | `branches-ignore: [main]` on `pull_request` |
| `tier3-handbook.yaml` | `branches-ignore: [main]` on `pull_request` |
| `handbook-build-check.yaml` | Runs on every `pull_request` (no branch
filter) |

## Workflows intentionally left develop/main only

| File | Why |
|---|---|
| `auto-tag.yaml` | Tags are created from `develop` / `main`. `staging`
is not a tag target. |
| `handbook-deploy.yaml` | DEV handbook deploys from `develop`, PRD from
`main`. `staging` is not a deploy target. |
| `release.yaml` | Tag-driven, independent of branch flow. |

## Follow-ups after this merges

1. Push the `staging` branch from `develop` HEAD.
2. Apply the same branch protection to `staging` as `develop` (require
CI checks + review).
3. Re-target the in-flight PRs from `develop` to `staging`.

## Test plan

- [ ] Branch protection on `staging` matches `develop`.
- [ ] After staging is created + a feature PR merges into staging,
`auto-staging-pr.yaml` opens the expected `staging -> develop` PR.
- [ ] Re-targeted feature PRs (e.g. the ongoing audit fixes) show their
`pull_request` CI runs as before.
## Summary

Documents the staging branch lane that was wired up by PR #623, so
contributors know where to target their PRs.

## What changes

- **`CONTRIBUTING.md`**: new "Branch Flow" section right after Build &
Test Commands. Names the three branches that participate in the release
lane (`staging`, `develop`, `main`), spells out that **feature PRs
target `staging`**, links the two auto-promotion workflows, and shows
the flow as ASCII.
- **`README.md`**: updates the "Typical patch flow" sentence so it
starts from `staging` instead of `develop`.

## Note

This is the first PR opened against `staging` — it doubles as a smoke
test for the new lane.

## Test plan

- [ ] After merge into `staging`, `auto-staging-pr.yaml` opens the
expected `Promote: staging → develop` PR.
## Summary

`auto-tag.yaml` set the SSH remote to a hardcoded
`git@github.com:DFXswiss/realunit-app.git`. After the recent transfer to
`RealUnitCH/app`, the next tag push would either rely on GitHub's 301
redirect (which `git push` over SSH does not follow reliably) or fail
outright.

Switching to `${{ github.repository }}` resolves to the current
`<owner>/<repo>` of the workflow run, so the remote tracks the repo
automatically across future renames and transfers.

## Verification

- [ ] Workflow YAML still parses (no shell quoting issues with the `${{
}}` substitution).
- [ ] Next push to develop triggers `auto-tag.yaml` and the tag lands
cleanly on the new origin.
## Summary

Scopes a `diffThreshold: 0.005` only on the `home_page_loaded` golden
via `AlchemistConfig.runWithConfig`. Every other golden stays at the
default `0.0` (exact-match).

## Why

The home_page_loaded golden renders a stroked price-chart path.
Sub-pixel anti-aliasing along that path produces coverage values that
jitter across runs even on the locked dfx01 renderer — hardware-lock is
not enough for stroked-path AA. Evidence:

- `044852b` (28.05.26, bot regen) refreshed `home_page_loaded` *and*
`buy_registration_required`
- `d25c61d` (01.06.26, #620) refreshed `home_page_loaded` again — only 4
days later, no UI change in scope
- Pixel diff between the two `home_page_loaded` versions: **0.24% of
pixels** changed, clustered in the chart-curve region only (rows
129–244, cols 252–385).

Both regens were the *correct* fix in the moment (a real pixel drift had
to be reconciled), but they're paying recurring cost for a structural
property of the chart widget.

## Why scoped, not global

A global `diffThreshold` would weaken the gate for **every** screen —
including 93 static-content goldens where 0.0 is exactly right. A small
icon removal (~256 px on 329 160-pixel surface) is well under 0.5% and
would be silently swallowed.

Local scope is the surgical fix: the chart-drift screen tolerates ~0.5%,
everything else stays strict.

## Why 0.005 (not lower or higher)

- Current chart drift: 0.238% of pixels → 0.005 absorbs it with ~2×
headroom for next time Skia jitters slightly more.
- Sanity ceiling: any meaningful UI regression in a 390×844 viewport
(icon, button, label) covers >2% — well above the threshold. The gate
still catches real changes.

## Alternative considered — not chosen

Hardening the chart with `StrokeJoin.bevel` + `StrokeCap.butt` in the
production code would remove drift at the source, but at the cost of
making the chart visually less smooth for end users to satisfy a test
concern. Wrong direction.

## Verification

- `flutter analyze test/goldens/screens/home/home_golden_test.dart` → No
issues found.
- Threshold is read at goldenTest registration time via `Zone.current`
(see `alchemist/src/golden_test.dart:163`), so the zone-based override
correctly reaches the comparator's `variantConfig.diffThreshold`
(`golden_test.dart:195`).
- The wrapper `goldenTest` from
`test/helper/golden_test_with_assets.dart` is preserved (alchemist
import uses `show AlchemistConfig, PlatformGoldensConfig` to avoid
ambiguous import on `goldenTest`).
## Problem

The Tier-3 \`Build iOS simulator app\` step failed on PR #626 with:

\`\`\`
Swift Compiler Error (Xcode): File
'/Applications/Xcode_16.4.app/.../iPhoneSimulator18.5.sdk/usr/lib/swift/shims/module.modulemap'
has been modified since the module file
'.../ModuleCache.noindex/.../SwiftShims-2ZSRUUIS75TOI.pcm' was built:
mtime changed (was 1779274009, now 1779861061)
\`\`\`

The cached \`SwiftShims-*.pcm\` referenced a previous SDK's
\`module.modulemap\` mtime — its own \`module.modulemap\` had been
rewritten by the runner image's Xcode upgrade between two macOS-latest
runs.

## Root cause

\`Cache iOS DerivedData + Pods\` keyed only on \`runner.os + lock
files\`. \`runner.os\` is just \`"macOS"\` regardless of the runtime
Xcode/SDK version, so a cache restored across an Xcode bump produces
\`.pcm\` files that point at SDK file mtimes from the **previous**
toolchain. Swift refuses to use them and the build fails.

## Fix

Resolve \`xcodebuild -version\` at runtime and inject it into both the
cache key and the restore-keys fallback. The cache now invalidates
exactly when the active Xcode rolls, so a hit can only restore objects
that were built against the current toolchain.

The version string is normalised (\`tr '\n' '-' | sed
's/[^A-Za-z0-9.-]//g; s/-\$//'\`) so \`Xcode 16.4 / Build version 16F6\`
becomes a safe cache-key fragment like
\`Xcode-16.4-Build-version-16F6\`.

## Validation plan

- [ ] On first run after merge, cache misses (new key includes Xcode
version that didn't exist in the old cache namespace) → clean build,
populates a fresh cache.
- [ ] Subsequent runs hit the cache as before, but only across the same
Xcode version.
- [ ] When the runner image upgrades Xcode next, the cache misses again
instead of restoring stale modules — same one-time clean-build cost, no
\`mtime changed\` failure.
Promote: staging -> develop
…lane-certificates

fix(ios): point Match at RealUnitCH/fastlane-certificates
## Automatic Staging PR

This PR was automatically created after changes were pushed to staging.

**Commits:** 1 new commit(s)

### Checklist
- [ ] Review all changes
- [ ] Verify CI passes
- [ ] Approve and merge to promote into develop

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
…dates (#635)

## Summary

Applies three product updates to the wallet-type selection (handbook
§02), the software-wallet-terms screen (handbook §03), and the Settings
page (handbook §12). The Step 1 / Step 2 onboarding architecture and the
Network mode itself stay; only the user-facing surface changes.

## §02 / §03 — wallet-type screen copy

Five strings on the wallet-type selection (handbook §02) and the
software-wallet-terms screen (handbook §03) are rewritten per the latest
product handbook copy. The two-step architecture (Step 1 =
Software-Wallet vs BitBox, Step 2 = Create vs Restore vs Debug) is
unchanged.

| ARB key | Old (DE / EN) | New (DE / EN) |
|---|---|---|
| `realunitWalletSubtitle` | Verwalten Sie Ihre RealUnit Token
kostenfrei und bankenunabhängig. / Manage your RealUnit tokens free of
charge and independently of banks. | Kaufen und verwalten Sie RealUnit
Aktientoken kostenfrei und bankenunabhängig. / Buy and manage RealUnit
stock tokens free of charge and independently of banks. |
| `softwareWallet` | Digitale Wallet (App) / Software Wallet |
Software-Wallet (App) / Software Wallet (App) |
| `softwareWalletSubtitle` | Ich möchte eine neue Wallet für den Handel
und die Aufbewahrung der RealUnit Token erstellen. / I would like to
create a new wallet for trading and storing RealUnit tokens. | Ich
möchte eine neue Wallet erstellen oder meine bestehende Wallet (z.B.
Aktionariat) wiederherstellen. / I want to create a new wallet or
restore my existing wallet (e.g. Aktionariat). |
| `bitbox` | BitBox / BitBox | BitBox Hardware-Wallet / BitBox Hardware
Wallet |
| `hardwareWalletSubtitle` | Verwahren Sie Ihre RealUnit Aktientoken auf
diesem separaten, physischen Gerät (einer "Hardware Wallet") aus der
Schweiz. / Store your RealUnit stock tokens on this separate, physical
device (a "hardware wallet") from Switzerland. | Ich besitze eine
Bitbox02 Nova und möchte RealUnit Token darauf verwahren. / I own a
Bitbox02 Nova and want to store RealUnit tokens on it. |

Reuse confirmed: the `bitbox` key is only referenced in
`lib/screens/welcome/welcome_page.dart:92` as the tile title — the
BitBox-disconnected / reconnect strings use separate keys
(`bitboxDisconnectedTitle`, `bitboxDisconnectedDescription`,
`bitboxReconnect`). The rename does not bleed into other screens.

`docs/handbook/de/index.html` §02 description rewritten to match the
renamed tiles and the broader software-wallet scope. §03 prose is
unchanged — the two-step architecture stays, and its screenshot picks up
the new header subtitle automatically via the shared welcome-page
golden.

## §12 — hide Network-mode row from production builds

The Settings → Network row (Mainnet/Testnet toggle) is a developer
affordance — an end-user has no legitimate reason to switch networks,
and an accidental tap drops them onto Testnet with broken liquidity and
a forced reauth on every subsequent network round-trip.

The `SettingOption` that opens `SettingsNetworkPage` is wrapped in a `if
(kDebugMode)` collection-if, mirroring the existing pattern in
`lib/screens/welcome/welcome_page.dart:125` (the **Address + Signature**
debug entry). `SettingsNetworkPage`, `SettingsRoutes.network`,
`SettingsBloc` network-mode state, `SettingsRepository.networkMode` —
all untouched. Default Mainnet stays active for everyone, debug builds
still expose the toggle.

## Goldens

Three golden surfaces drift on this PR:

- `welcome/goldens/macos/welcome_page_ios.png` (handbook §02)
- `welcome/goldens/macos/welcome_page_second_step.png` (handbook §03 —
header subtitle pulls the new copy)
- `settings/goldens/macos/settings_page_default.png` (handbook §12 —
Network row gone)

Visual Regression will fail on this PR until the goldens are regenerated
on the dfx01 self-hosted runner via the `golden-regenerate.yaml`
workflow. Once regenerated and pushed, Visual Regression turns green and
the handbook screenshots refresh automatically (the mapping in
`scripts/assemble-handbook-screenshots.sh` already points at these three
files).

## Out of scope

- §01 (welcome splash claim `Bankenunabhängig. Sicher.` → `Sicher.
Einfach. Bankenunabhängig.`) — the claim is baked into
`assets/images/splash/splash_background.png`; needs a fresh asset from
the designer before it can land.

## Test plan

- [ ] `dart run tool/generate_localization.dart` regenerates
`lib/generated/i18n.dart` with the new strings (already committed).
- [ ] `flutter analyze` clean on the touched files.
- [ ] Visual Regression: after golden regeneration,
`welcome_page_ios.png`, `welcome_page_second_step.png`, and
`settings_page_default.png` show the new copy / layout and the handbook
screenshots pick them up.
- [ ] Production build of the Settings page no longer lists the Network
row; debug build still does.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Promote: staging -> develop
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants