Skip to content

Release: develop -> main - #921

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

Release: develop -> main#921
github-actions[bot] wants to merge 10 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: 7 new commit(s)

Checklist

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

joshuakrueger-dfx and others added 10 commits August 11, 2026 23:29
…897)

Behebt #896: Die Bildbeschreibung von Handbuch-Block
`251-support-create-ticket-page-filled` sagt
„mit ausgewähltem Anliegen-Typ und **eingegebener Nachricht**", die
Baseline zeigte aber den grauen
Platzhalter **Nachricht eingeben**. Dieser PR ändert den Test, der das
Bild erzeugt, und die
Baseline — der veröffentlichte Handbuch-Screenshot 251 ändert sich damit
mit.

**Not symptom-driven:** Keine Störung in Produktion. Auslöser ist ein
Review-Befund auf #893, dort
bewusst nicht mitbehoben. Der Widerspruch ist am Artefakt geprüft, nicht
aus dem Issue übernommen:
Baseline angesehen (Platzhalter statt Text, Senden aktiv), Ursache im
Code belegt — das `TextField`
in `support_create_ticket_page.dart` ist unkontrolliert und treibt den
Cubit nur über `onChanged`,
der gemockte `state.message` erreicht den Render-Baum nie.

**Scale:** Ein Handbuch-Block, eine Baseline, ein Golden-Test. Die
Fläche ist die Beschreibung
selbst — für Nutzer mit Screenreader **ist** sie das Bild, und sie
zählte ein Formularfeld auf, das
sichtbar leer war. Genau diese Klasse wurde in #893 für
250/251/252/258/259 nachgezogen.

**Smaller fix considered:** Nur den Beschreibungstext umschreiben („Feld
noch leer, Senden trotzdem
aktiv") — 2 Zeilen, verworfen: dann zeigt das Handbuch dauerhaft einen
Zustand, den es in Produktion
nicht gibt (aktiver Senden-Knopf bei sichtbar leerem Pflichtfeld). Der
gewählte Weg macht Bild und
Beschreibung beide wahr; das Handbuch-HTML bleibt unverändert.

## Was drin ist

`pumpBeforeTest` tippt denselben Text, den der State trägt, räumt den
Fokus ab und lässt die
`InputDecorator`-Rückblende `focusedBorder` → `enabledBorder` ablaufen.
Das Bild zeigt danach, was
ein Nutzer nach dem Tippen sieht: Text im Feld, kein Cursor, grauer
Rahmen wie im Nachbarbild 250.

Zwei Assertions halten das fest — beide **an einer plausibel-falschen
Variante rot geprüft**, nicht
nur an ihrer Abwesenheit:

| Mutation | Ergebnis |
|---|---|
| anderer Text getippt als im State | rot — `Found 0 widgets with text
"Ich habe eine Frage zu meinem Konto."` |
| `unfocus`-Zeile entfernt | rot — `Expected: false / Actual: <true>` |

Die erste sichert den Kern des Fixes: ein still wirkungsloses
`enterText` würde sonst wieder ein
leeres Feld einfrieren — genau der Fehlermodus, um den es hier geht. Die
zweite sichert, dass weder
Cursor noch Fokusrahmen ins Bild geraten. Ohne das `pumpAndSettle`
friert der Rahmen auf der
Fokusfarbe ein: Pixel `(20, 310)`, das linke Rahmenpixel des Felds,
liest dann `(25, 136, 198)`
statt `(226, 232, 240)`. Das war ein Befund am erzeugten Bild, nicht am
Code.

Der Umfang ist bewusst auf diesen Zuschnitt begrenzt. Aus Review-Runden
waren zwischenzeitlich
weitere Assertions (Senden-Knopf, Chip-Auswahl, ein Scheduler-Wächter),
ein `precacheImages`-Aufruf
und die Vereinheitlichung der Nachrichten-Konstante über alle Goldens
der Datei dazugekommen — alles
zurückgenommen: die fünf Nachbar-Goldens derselben Datei tragen kein
solches Gerüst, und das Issue
verlangt ausdrücklich einen Eingriff, der *nur diese eine Baseline*
trifft. Dass die Goldens nach
dem Rückschnitt weiter 6/6 grün sind, belegt zugleich, dass keine dieser
Zutaten je das Bild
beeinflusst hat.

## Baseline

Regeneriert auf dem self-hosted Runner
([Lauf
31171522710](https://github.com/RealUnitCH/app/actions/runs/31171522710)),
nicht auf meinem
Mac — damit sie unter derselben Toolchain rendert, die sie auf PRs
validiert. Der Bot-Commit war
unsigniert; seine Bytes liegen hier als eigener **signierter** Commit,
SHA256 gegen die
Runner-Ausgabe geprüft (`7c15a7a1f76b83fe…`), der Bot-Commit wurde dafür
per Force-Push ersetzt.
Der Branch hat nur einen Autor und keine Reviews, es ging dabei nichts
verloren.

Nebenbefund: Runner und lokaler Mac haben das Bild **byte-identisch**
erzeugt — derselbe SHA256.
Für diese Fläche ist die Toolchain also deckungsgleich.

Der Pixel-Diff der Baseline ist auf die Textzeile begrenzt: Bounding-Box
x37–337 / y267–283,
2122 Pixel = 0,64 %. Rahmen und Senden-Knopf sind byte-gleich zum
vorherigen Bild. Der Screenshot
enthält keinen QR-/Barcode (`zbarimg`, Exit 4) und keine Zugangsdaten —
nur Formularfelder und den
Satz „Ich habe eine Frage zu meinem Konto.".

## Verifikation

`flutter analyze` ohne Befund. `flutter test` auf der Golden-Datei gegen
die neue Baseline:
**6 von 6 grün**. Vor der Regenerierung war es 5 grün / 1 rot mit `Pixel
test failed, 0.64%, 2122px`
— ein Bildvergleich, kein Assertion-Fehler; die anderen fünf Baselines
stimmten dabei pixelgenau,
der Vergleich war also aussagekräftig.

Drei unabhängige Prüfpässe sind gelaufen (Konformität gegen
`CONTRIBUTING.md`, Logik/Korrektheit mit
eigenen Mutationen, Katalog-Pass gegen die Review-Historie dieses
Repos); ihre Funde sind
eingearbeitet, soweit sie im Zuschnitt dieses Issues liegen.

## Bewusst nicht angefasst

- **Golden 252** (`submitting`): zeigt weiterhin das leere Feld — und
trägt damit **dieselbe
Unerreichbarkeit** wie 251 vor diesem PR: `canSubmit` verlangt
`message.trim().isNotEmpty`
(`support_create_ticket_state.dart:46-50`), ein Absendevorgang mit
sichtbar leerem Pflichtfeld
kommt in Produktion nicht vor. Dass die Beschreibung dort keinen
sichtbaren Text behauptet, macht
das Bild wörtlich nicht falsch, deckt die Unerreichbarkeit aber nicht ab
— die frühere Begründung
an dieser Stelle war zu schwach. Der Grund fürs Draußenlassen ist der
Zuschnitt, nicht die
Harmlosigkeit: Issue #896 führt 252 ausdrücklich als „nicht betroffen",
und technisch ist das
`TextField` dort über `enabled: !state.isSubmitting` deaktiviert,
`enterText` greift ohne eigene
State-Choreografie nicht. Gehört in einen Folge-PR, zusammen mit den
drei übrigen Goldens dieser
Datei, die `message:` ohne Tippen setzen (`attached`, `error_snackbar`,
`success_snackbar`).
- **Golden `attached`**: hat keinen Handbuch-Block. Von 302
macOS-Baselines sind 279 im Mapping von
`assemble-handbook-screenshots.sh`, 23 nicht — es gibt also keinen
Vollständigkeitsanspruch,
und ohne Beschreibung auch keinen Widerspruch. Der Referenzgraph ist in
beide Richtungen gezählt:
279 Mapping-Zeilen, 279 eindeutige Ziele, kein Ziel mit mehreren
Quellen, kein totes Ziel.
- **Engere Finder** (`find.widgetWithText`): trägt hier nicht, weil der
Hinweistext nach dem Tippen
verschwindet. Ein zweites Textfeld ließe `enterText` mit „Found 2
widgets" **laut** scheitern,
  nicht still durchlaufen.
- **Drei vorbestehende Zeilennummern-Verweise** in Kommentaren derselben
Datei (`cubit:44`,
`page:40-48`, `page:49-56`) — alle drei zeigen inzwischen ins Leere.
Vorbestand, eigener Vorgang.


## Schlusspass

**Final pass (c984139):**
**Coherent:** Der Zustand, den das neue Bild zeigt, ist der **einzige in
Produktion erreichbare** —
`updateMessage` hat in `lib/` genau einen Aufrufer
(`support_create_ticket_page.dart:120`,
`TextField.onChanged`), der Cubit wird an genau einer Stelle erzeugt
(`page:23`) und startet immer
mit leerem `message`; es gibt keinen Restore-, Draft- oder
Deep-Link-Pfad. Die alte Baseline zeigte
damit einen Zustand, den kein Nutzer je sieht. Das unkontrollierte
`TextField` ist deshalb kein
Fehler, den dieser PR verdeckt, sondern korrekt: es gibt keine zweite
Quelle für den Text. Die
Abweichung vom Repo-Muster ist begründet — `restore_wallet` kommt mit
zwei `pump()` aus, weil
`mnemonic_input_field.dart:43` `border: InputBorder.none` setzt und der
sichtbare Rahmen dort ein
statischer `Border.all` ist (`mnemonic_field_base.dart:25`); unser Feld
hat einen animierten
`focusedBorder` (`page:136-141`) und braucht darum `pumpAndSettle`.
**Nothing extra:** Zurückgenommen wurden fünf Zutaten aus früheren
Review-Runden (Senden-Knopf-
Assertion, Chip-Assertion, Scheduler-Wächter, `precacheImages`,
Konstante über alle Goldens); übrig
bleibt der kleinste Eingriff, der Bild und Beschreibung in Deckung
bringt. Die entfernte `pump()`-
Zeile ist gegengeprüft: wieder eingefügt bleiben die Goldens 6/6 grün
und der Pixelvergleich
unverändert — sie war wirkungslos. Die beiden Assertions sind kein
Übermaß neben dem
Pixelvergleich: sie halten den Fehlermodus auch dann fest, wenn die
Baseline später regeneriert
wird und ein leeres Feld sonst stillschweigend einfrieren würde. Die
Konstante koppelt genau die
zwei Stellen, zwischen denen die Invariante gilt (`enterText` ↔
`state.message`); die vier
Nachbar-Goldens haben kein `enterText` und bleiben deshalb bewusst
unangetastet.
**Sources closed:** Issue #896 (Weg 2 umgesetzt, Handbuch-HTML
unangetastet); Commit-Messages
decken den Diff; Review-Kanäle: 0 Reviews, 0 Inline-Kommentare, 1
Issue-Kommentar von TaprootFreak
vom 07.08. — beantwortet mit den auf dieser SHA gemessenen Zahlen; die
Rückfrage, ob ihm ein
förmlicher Review-Eintrag oder etwas Inhaltliches fehlt, steht bei ihm.

- **Konsistent logisch gebaut:** Der Test stellt genau den Zustand her,
den das Bild zeigen soll;
beide Assertions sichern je eine Eigenschaft, die sonst still falsch
werden könnte. Der Kommentar
beschreibt den Mechanismus — keine Zeilennummern, keine Nachbardateien,
keine Begründung, die
  der Code nicht hergibt.
- **Nicht zu viel gebaut:** Der Diff ist der Kern des Issues —
`enterText`, `unfocus`,
`pumpAndSettle`, zwei Assertions, eine Konstante, die Baseline. Fünf
Zutaten aus früheren
Review-Runden (Senden-Knopf-Assertion, Chip-Assertion,
Scheduler-Wächter, `precacheImages`,
Konstante über alle Goldens) wurden **zurückgenommen**, weil das Issue
einen Eingriff verlangt,
der nur diese eine Baseline trifft, und die fünf Nachbar-Goldens
derselben Datei kein solches
Gerüst tragen. Zwei Belege dafür, dass der Rückschnitt nichts aufgibt:
die Goldens sind danach
weiter 6/6 grün (keine der Zutaten hat je das Bild beeinflusst), und die
beiden Eigenschaften
ohne eigene Assertion hält der Pixelvergleich messbar — leerer `message`
ergibt 4,98 %,
ein anderer Chip 3,55 % Diff. Dazu eine belegt wirkungslose
`pump()`-Zeile entfernt.
- **Issue vollständig umgesetzt:** #896 nennt zwei Wege und tendiert zu
Weg 2 („das Golden echt
befüllen … danach passt die bestehende Beschreibung ohne Änderung").
Genau das ist umgesetzt:
das Handbuch-HTML ist unangetastet, Block 251 stimmt jetzt mit seinem
Bild überein. Die im Issue
als „nicht betroffen" genannten Blöcke 250, 252, 258, 259 sind
unverändert.
- **Reviews umgesetzt oder begründet abgelehnt:** Eingearbeitet sind der
Fokus-/Rahmenfehler, die
entfernten Cross-File-Kommentarverweise, die tote `pump()`-Zeile und die
falsche Begründung im
Doc-Kommentar. Begründet abgelehnt und dokumentiert: engere Finder,
Golden 252, Golden
`attached`, die drei vorbestehenden Zeilennummern-Verweise, die
Konstante in den vier
  Nachbar-Goldens.
- **Veröffentlichter Inhalt geprüft:** Das PNG geht auf
handbook.realunit.app. Kein QR-/Barcode
(`zbarimg`, Exit 4), keine Zugangsdaten, keine Adressen — Formularfelder
und ein Beispielsatz.
- **Geprüft auf dieser SHA:** volle Suite `flutter test` — **5244 Tests,
alle grün**, Exit 0, keine
fehlgeschlagene Suite. Die Zahl ist gegen die CI quergeprüft, die
dieselbe Menge in zwei Läufen
fährt: 302 Golden-Tests (`flutter test test/goldens`) plus 4941
bestandene und 1 übersprungener
Test (`flutter test --coverage --exclude-tags golden`) — zusammen
dieselben 5244.
`flutter analyze` ohne Befund. Drei Mutationsproben auf dem neuen Test,
jede **5 grün / 1 rot**: anderer Text getippt
(`Found 0 widgets`), `unfocus` entfernt (`Expected: false / Actual:
<true>`), `pumpAndSettle` →
`pump` (`Pixel test failed, 0.30%, 1000px`). Die Regenerierung ist
einmal ausgeführt worden: lokal
neu erzeugt ergibt denselben SHA256 `7c15a7a1f76b83fe…`, `git` sieht
keine Änderung. Mechanischer
Katalog-Check `tf-test.sh`: 0 Blocker. CI auf dieser Revision: 5 Checks
grün, 1 übersprungen
(Maestro), Annotationen gelesen — zwei Warnungen, beide Vorbestand (lcov
meldet keine
  Function-Coverage; `actions/checkout@v4` auf Node 20).
EN:
Copy icons on the bank-transfer details screen were a 16px tap target,
so they were hard to hit on iPhone. They are now 44×44 IconButtons. IBAN
still copies ungrouped; the remittance reference copies as well.

DE:
Die Kopier-Icons auf den Zahlungsdetails waren nur 16 Pixel gross und
auf dem iPhone kaum treffbar. Sie sind jetzt 44×44. Die IBAN wird weiter
ohne Leerzeichen kopiert, der Verwendungszweck ebenfalls.

<details>
<summary>Details</summary>

Incident: a customer could not copy payment details from the RealUnit
app into the banking app on iPhone.

Change: `_PaymentDetailsRow` uses `IconButton` with a tight 44×44
constraint instead of a 16px `InkWell` around the icon. Tooltip uses
`MaterialLocalizations.copyButtonLabel`.

Tests in `payment_details_card_test.dart` assert the tappable control is
at least 44×44 and that tapping the purpose-of-payment copy control
writes the remittance reference.

</details>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ation gate (#908)

EN:
The buy-page CTA forwards the API's KYC context on the
`registrationRequired` and `kycRequired` gates but not on the
`primaryEmailNotConfirmed` one, so a re-check after confirming email was
scored against the app's full KYC step set instead of the
`RealunitBuy`-scoped subset. A buyer who already finished `Ident` could
be reported `InProgress` for steps `RealunitBuy` deliberately excludes
from the buy gate. This closes item 3 of DFXswiss/backend#4556.

DE:
Der Buy-Seiten-CTA reicht den von der API gelieferten KYC-Kontext bei
den Gates `registrationRequired` und `kycRequired` weiter, beim Gate
`primaryEmailNotConfirmed` bisher jedoch nicht — dadurch wurde eine
erneute Prüfung nach der E-Mail-Bestätigung gegen den vollständigen
KYC-Schritt-Satz der App statt gegen die `RealunitBuy`-Teilmenge
bewertet. Ein Käufer, der `Ident` bereits abgeschlossen hatte, konnte
dadurch als `InProgress` gemeldet werden wegen Schritten, die
`RealunitBuy` bewusst vom Buy-Gate ausschliesst. Schliesst Item 3 von
DFXswiss/backend#4556 ab.

<details>
<summary>Details</summary>

## Problem

`PaymentActionButton`'s `primaryEmailNotConfirmed` branch pushed
`AppRoutes.kyc` with no `extra`, unlike its
`registrationRequired`/`kycRequired` siblings a few lines above, which
already pass `extra: paymentState.context`.

That alone wasn't the whole gap: `paymentState.context` was itself
always `null` on this path. `BuyPaymentInfoFailure.context` is populated
from a thrown exception's server-supplied field for the
`kycRequired`/`registrationRequired` branches (`e.context`), but
`primaryEmailNotConfirmed` is a `QuoteError` code embedded directly in
the quote response body — a different shape with no context field to
read.

Losing the context here means `KycInfoMapper.toDto` (API side) computes
`processStatus` over the full `requiredKycSteps` set instead of the
`RealunitBuy`-scoped subset. Concretely: a buyer who already finished
`Ident` but hasn't confirmed their primary email hits this gate, taps
through to KYC, confirms the email, and on the subsequent re-check could
still be reported `InProgress` — not because anything buy-relevant is
outstanding, but because `FinancialData`/`DfxApproval` (steps
`RealunitBuy` context deliberately excludes from the buy gate) are
unfinished.

## Change

Three parts:
1. `payment_action_button.dart` — forward `extra: paymentState.context`
on the `primaryEmailNotConfirmed` push, mirroring the two branches above
it.
2. `buy_payment_info_cubit.dart` — populate that context in the first
place: the `primaryEmailNotConfirmed` `BuyPaymentInfoFailure` is now
constructed with `context: 'RealunitBuy'`. Since this cubit is
unconditionally the RealUnit buy flow, the value is a static call-site
identifier rather than a server decision, hardcoded as the same literal
already used for the sibling branches
(`test/screens/buy/cubits/buy_payment_info_cubit_test.dart`'s existing
`... with context` tests).
3. `payment_action_button_test.dart` — the mock KYC route builder
previously discarded `GoRouterState` entirely, so the forwarding in (1)
had no regression coverage; it now captures `state.extra` and the
relevant test asserts it equals `'RealunitBuy'`.

The `primaryEmailRequired` branch (routes to email capture, never to
`/kyc`) is untouched — it has nothing to scope.

## Verification

- `flutter analyze` — no issues.
- `flutter test test/screens/buy/cubits/buy_payment_info_cubit_test.dart
test/screens/buy/widgets/payment_action_button_test.dart` — 24/24
passed.
- Mutation-tested the new widget assertion by temporarily reverting the
`extra` forwarding in `payment_action_button.dart` and confirming the
test goes red, then restored it.

</details>
…code (#909)

## Summary

- The phone number field on the registration and personal-data steps was
hard-limited to a closed `+41` / `+49` dropdown, blocking customers with
  other prefixes (e.g. Austria, Swiss citizens living abroad) from
  registering with their real number.
- Replaces the dropdown with a free-text prefix field, prefilled `+41`,
  with a fixed non-deletable `+` prefix (`InputDecoration.prefixText`).
The client checks format only (1 to 3 digits) and defers whether a given
  dial code actually exists to the API, per CONTRIBUTING ("the API
decides") -- the national-number field right next to it already follows
  that rule.
- Restores `+423` (Liechtenstein) to the seeded-value decomposition list
  with longest-first matching, so a stored `+423...` number is no longer
  silently misread as `+41` on re-entry.
- Updates the twin test assertions that referenced the old dropdown
  (widget count, `find.text`) and drops the now-orphaned
  phone-prefix-dropdown golden case and its handbook references.

**Symptom (verbatim):** Product owner forwarded a customer-facing
report:
Austrian customers and Swiss citizens living abroad cannot register with
their real phone number, because the country-prefix dropdown only offers
`+41` and `+49`.

**Scale:** At least one concrete customer report. The affected class is
every registration outside CH/DE -- explicitly named: Austria and Swiss
citizens abroad (potentially any country). The prior dropdown covered 2
of roughly 200 possible international dial codes.

**Smaller fix considered:** Adding a few more hardcoded entries to the
closed dropdown list (e.g. `+43`, `+423`) was rejected -- it would still
be a closed, arbitrarily-bounded list needing a follow-up PR for the
next
missing country, repeating the same complaint. A full country-code
picker
package (search UI, flags) was also rejected as unnecessarily large for
this fix: new dependency, and a second country-list data source parallel
to the existing DFX-API-backed one already used for nationality.

## Test plan

- [x] `flutter test test/widgets/form/phone_number_field_test.dart
test/screens/kyc/steps/kyc_personal_data_page_test.dart` -- 25/25 green
  (12 + 13)
- [x] Counter-check 1 (dropdown removal + twin-spot fixes): 11/23 red
  without those changes
- [x] Counter-check 2 (1-3 digit widening on top): 3/11 red in
  `phone_number_field_test.dart` alone without that specific change
- [x] Counter-check 3 (empty-prefix required-error test): 1/12 red when
the
  validator's empty check is mutated out
- [x] `flutter analyze` on all touched Dart files -- no issues
- [x] Verified live in the iOS Simulator: field shows `+41` by default,
  accepts any prefix from 1 to 3 digits (e.g. `+43`, `+423`), `+` cannot
  be deleted, a 4th digit is rejected
- [x] Golden-image baselines regenerated via the `golden-regenerate`
workflow
(11 PNGs, bytes verified against the bot commit before taking over as a
signed commit) -- all CI checks green: Analyze & Test, Visual
Regression,
  Build handbook image + container smoke, BitBox quirks audit, Coverage
  Floor Gate.

**Final pass (25cb745):**
**Coherent:** All 5 commits serve the one title -- open the phone-prefix
field. Commit 1 is the feature itself; commits 2-3 are direct fixes to
findings from two independent reviews of that same feature (dial-code
range, missing required-prefix test); commits 4-5 are the necessary
consequences of the UI change (golden baselines, handbook screenshot
count) -- no unrelated file is touched.
**Nothing extra:** No new dependency, no country-picker package, no
changes to `dropdown_field.dart`/`country_field.dart` or any of the
three
`PhoneNumberField` callers. The smallest fix (dropdown to free-text
field)
was widened from 2 to 1-3 digits only because review found it excluded
real dial codes and violated CONTRIBUTING's "the API decides" -- not for
speculative completeness.
**Sources closed:** No linked GitHub issue (reported via a forwarded
customer email, not a tracked issue). All three review channels checked
via API (`pulls/909/reviews`, `pulls/909/comments`,
`issues/909/comments`)
-- all empty, no external feedback pending. Two independent judgment
passes (TaprootFreak-catalog reviewer + Conformity/Logic pair) each
found
one real issue; both are fixed in commits 2 and 3 respectively and
re-verified.
…919)

EN:
The handbook PRD deploy failed because the default token cannot clone
the mail-preview source repository.
This change checks that repository out with a read-only deploy key,
drops those credentials after checkout, and fails loud if the secret is
missing.

DE:
Der Handbook-PRD-Deploy ist fehlgeschlagen, weil der Default-Token die
Mail-Preview-Quelle nicht klonen kann.
Der Checkout läuft über einen read-only Deploy-Key, die Credentials
werden danach verworfen, und ohne Secret bricht der Job klar ab.

<details>
<summary>Details</summary>

The `Handbook CI/CD` job `deploy-prd` failed on push to `staging` at
`Check out DFXswiss/api at develop` with HTTP 404. That step is not part
of
the promotion PR's review CI; Analyze & Test on the auto-PR stayed
green.

`handbook.yaml` now requires `HANDBOOK_API_CHECKOUT_SSH_KEY`, rejects an
empty value, passes it to `actions/checkout@v4` as `ssh-key`, and sets
`persist-credentials: false` so later steps do not keep that key.
`handbook-deploy.yaml` forwards the repo secret. The PR-only handbook
build check is unchanged and still does not clone that source.

The deploy key is read-only. `RealUnitCH/web` stays on the default
token.

</details>
…920)

EN:
The handbook production deploy failed because the web-baseline guard
still expected 22 Playwright PNGs after RealUnitCH/web added 14 for
`/account-merge/`.
This change raises `EXPECTED_WEB_BASELINE_COUNT` to 36 and adds matching
cards in `#spec-web`.
The cards describe adding a wallet address, not merging two accounts.
After this lands on staging, the staging-to-develop promotion can run
that deploy again.

DE:
Der Handbook-Produktions-Deploy ist fehlgeschlagen, weil der
Web-Baseline-Guard noch 22 Playwright-PNGs erwartet hat, nachdem
RealUnitCH/web 14 für `/account-merge/` ergänzt hat.
Diese Änderung setzt `EXPECTED_WEB_BASELINE_COUNT` auf 36 und ergänzt
die passenden Karten in `#spec-web`.
Die Karten beschreiben das Hinzufügen einer Wallet-Adresse, nicht das
Zusammenführen zweier Konten.
Sobald das auf staging liegt, kann der Promote staging→develop den
Deploy erneut fahren.

<details>
<summary>Details</summary>

The auto-promotion PR #904 (`Promote: staging -> develop`)
is red on `deploy-prd / Build and deploy to PRD` only:

```
Expected exactly 22 web baseline PNGs in _web-checkout/tests/__screenshots__, got 36.
```

Analyze & Test, Visual Regression, Coverage Floor Gate, and the PR-only
Handbook Build Check stayed green: that last job does not clone
RealUnitCH/web, so it never sees the count.

Cause: RealUnitCH/web#25 committed 14 new Playwright baselines for
`/account-merge/` (add a wallet address to an existing account). The
previous handbook update of this guard was #862 (18 → 22,
no-registration confirm states). There was no open PR covering 22 → 36.

New files (14):

| Viewport | Files |
|---|---|
| desktop-chromium | merge-confirmed(.png + -en.png), merge-invalid(.png
+ -en.png), merge-already-completed(.png + -en.png),
merge-unavailable(.png + -en.png) |
| tablet-chromium | merge-confirmed.png, merge-invalid.png (no tablet
shots exist for already-completed / unavailable) |
| mobile-safari | merge-confirmed-mobile.png, merge-invalid.png,
merge-already-completed.png, merge-unavailable.png |

Same markup as the existing confirm cards in `#spec-web`. Intro and
`docs/handbook/README.md` now name `/account-merge/` next to
`/confirm-aktionariat`.

No Dart, no golden regeneration, no other workflow files.

</details>
Promote: staging -> develop
…tory filter (#907)

EN:
The transaction-history filter compared the end bound against the picked
day at local midnight, so every transaction timestamped later on that
day was dropped (#779). The bound is now the start of the following
local day, compared exclusively, which makes the selected end day fully
inclusive. Three regression tests cover the same-day-afternoon case, the
day after the bound, and a UTC-flagged timestamp falling on the local
end day.

DE:
Der Transaktions-Filter verglich die obere Grenze gegen den gewählten
Tag um lokale Mitternacht, wodurch jede später an diesem Tag datierte
Transaktion herausfiel (#779). Die Grenze ist jetzt der Beginn des
Folgetags in lokaler Zeit, exklusiv verglichen, womit der gewählte
Endtag vollständig enthalten ist. Drei Regressionstests decken den
Nachmittagsfall, den Tag nach der Grenze und einen UTC-markierten
Zeitstempel am lokalen Endtag ab.

<details>
<summary>Details</summary>

## Root cause

`DatePickerField` → `DatePicker.pickDate` returns the selected day as
`DateTime(y, m, d)`, i.e. **local midnight**.
`TransactionHistoryFilterCubit._applyFilter` treated that value as the
inclusive upper bound:

```dart
final beforeEnd = endDate == null || !transactionDate.isAfter(endDate);
```

For an end date of `2026-04-01` that bound is `2026-04-01 00:00:00.000`
local, so a transaction at `2026-04-01 14:00` satisfies `isAfter(bound)`
and is excluded. Only transactions at exactly 00:00:00.000 on the
selected end day survived — an off-by-one day on the upper edge of every
range the user picks.

The start bound is not affected: local midnight is already the correct
*start* of the picked day, so a picked start date behaves as intended.

## Fix

```dart
final endBound = endDate == null ? null : _startOfNextLocalDay(endDate);
...
final beforeEnd = endBound == null || transactionDate.isBefore(endBound);
```

with

```dart
DateTime _startOfNextLocalDay(DateTime date) {
  final local = date.toLocal();
  return DateTime(local.year, local.month, local.day + 1);
}
```

Two deliberate details:

- **Exclusive next-day bound rather than `23:59:59.999`.** `DateTime`
carries microsecond resolution, so an end-of-day sentinel would still
leave a sub-millisecond hole. `isBefore(startOfNextDay)` has no gap.
- **`DateTime(y, m, d + 1)` rather than `.add(const Duration(days:
1))`.** `Duration` adds 24 absolute hours; on a DST-transition day that
lands at 23:00 or 01:00 local instead of the next local midnight. The
constructor normalises overflowing day values and resolves the
wall-clock instant correctly, including across month and year ends.

## The zone concern raised in the issue

Checked and **not a defect**: `DateTime.isBefore` / `isAfter` compare
`microsecondsSinceEpoch`, i.e. absolute instants, so a UTC-flagged
`timestamp` (from `DateTime.parse` on an API `...Z` string) is compared
correctly against a local bound — no cross-boundary drift. The trap the
team has hit before is `DateTime.==` / equality, which additionally
compares the `isUtc` flag; this comparison path never uses it.

What *does* matter for zone consistency is which calendar day the bound
denotes. `TransactionHistoryRow` renders
`transaction.timestamp.toLocal()`, so the user reads local days; the
bound is therefore normalised via `toLocal()` before the day components
are taken, so the filtered range matches the days shown in the list. The
added `toLocal()` is a no-op for the values the picker produces and
guards the bound against a caller passing a UTC-flagged `DateTime`.

## Blast radius

`state.filtered` also feeds `TransactionHistoryDownloadButton`, so the
exported statement inherits the same correction — a statement generated
with an end date of "today" previously omitted everything from today.

## Tests

Added to
`test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart`:

1. `changeFilter keeps transactions timestamped later on the selected
end day (issue #779 regression)` — 00:00, 14:00 and 23:59:59 on the end
day; expects all three. Fails on the old code with 1 of 3.
2. `changeFilter still excludes the day after the selected end date` —
guards against over-correcting the bound by a day.
3. `changeFilter includes a UTC-flagged timestamp falling on the local
end day` — pins the instant-vs-flag semantics described above.

One existing test name (`changeFilter includes the boundaries (isBefore
/ isAfter, not isAtSameMoment)`) was shortened to `changeFilter includes
the boundary days`, since it no longer describes the implementation it
names. Its assertions are unchanged, and all four pre-existing filter
tests keep passing under the new bound.

**Not executed locally:** no Flutter toolchain was available in the
environment this change was prepared in, so `flutter test` and `flutter
analyze` have not been run against it — CI on this PR is the first
execution. The diff was reviewed by hand against the existing test
file's structure and the repo's `page_width: 100` formatter setting.

</details>
EN:
API error text is shown as-is. The app no longer substitutes local copy
for quote, confirm, sell, send, or KYC failures. Hardware and
cancelled-signature messages stay local.

DE:
API-Fehlertexte werden unverändert angezeigt. Die App ersetzt sie bei
Quote, Confirm, Sell, Send und KYC nicht mehr durch lokale Texte.
Hardware- und Abbruch-Meldungen bleiben lokal.

<details>
<summary>Details</summary>

Confirm snackbar: `BuyConfirmFailure.message` from the API.

Quote 503 / unknown: `BuyPaymentInfoFailure.message` from the API; the
info card no longer uses `priceProviderUnavailable*` /
`paymentInformationFailed*`.

Sell 503 snackbar: `state.message` instead of
`priceProviderUnavailableTitle`.

Send: API `message` is the sheet body when present; ARB remains only for
BitBox/signature-cancelled.

KYC snackbars (email, nationality, personal data, link wallet,
registration, 2FA, ident error): API `message` as-is.

Depends on the backend PR that returns user-facing `message` fields.

</details>

---------

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.

3 participants