diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml
index 788b7a7e5..6ea8dcfd5 100644
--- a/.github/workflows/handbook-build-check.yaml
+++ b/.github/workflows/handbook-build-check.yaml
@@ -92,8 +92,8 @@ jobs:
set -euo pipefail
bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots
count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ')
- if [ "$count" != "284" ]; then
- echo "expected 284 screenshots, got $count" >&2
+ if [ "$count" != "299" ]; then
+ echo "expected 299 screenshots, got $count" >&2
exit 1
fi
@@ -173,11 +173,11 @@ jobs:
exit 1
fi
- # Screenshots dir must contain all 284 PNGs assembled from Goldens.
+ # Screenshots dir must contain all 299 PNGs assembled from Goldens.
# Hit one of them through the auth gate to verify wiring end-to-end.
# Mix of the original 01-61 range and the 62-269 batch (every Golden
# baseline) so a regression in either half surfaces here.
- for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j; do
+ for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo; do
code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png")
# 200 (auth happens to match) or 401 (auth fails but file exists)
# both prove the file is on disk. 404 means it was not assembled.
diff --git a/.maestro/handbook/11-dashboard.yaml b/.maestro/handbook/11-dashboard.yaml
index a24a2bd08..85b04f5b7 100644
--- a/.maestro/handbook/11-dashboard.yaml
+++ b/.maestro/handbook/11-dashboard.yaml
@@ -35,3 +35,9 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'RealUnit kaufen'
timeout: 30000
+# Fresh handbook wallet is not a verified shareholder with 70 REALU
+# (TB Ziff. 2, analog Verkaufen). The dashboard card must stay off this
+# screen; 289-dashboard-referral-entry-and-payout is golden-only.
+- extendedWaitUntil:
+ notVisible: 'Erhalte 20 REALU pro Weiterempfehlung'
+ timeout: 10000
diff --git a/.maestro/handbook/12-settings.yaml b/.maestro/handbook/12-settings.yaml
index 2491e4850..f33254f54 100644
--- a/.maestro/handbook/12-settings.yaml
+++ b/.maestro/handbook/12-settings.yaml
@@ -24,3 +24,13 @@ appId: swiss.realunit.app
- extendedWaitUntil:
visible: 'Einstellungen'
timeout: 30000
+# Same gate as the dashboard card. Wait for a settings tile that does not
+# depend on the referral API, then assert Empfehlungen is still absent so
+# a late summary 200 cannot have opened the tile on a fresh wallet.
+- extendedWaitUntil:
+ visible:
+ text: '.*Rechtsdokumente.*'
+ timeout: 15000
+- extendedWaitUntil:
+ notVisible: 'Empfehlungen'
+ timeout: 8000
diff --git a/.maestro/handbook/20-settings-legal-documents.yaml b/.maestro/handbook/20-settings-legal-documents.yaml
index d75298723..e11b27811 100644
--- a/.maestro/handbook/20-settings-legal-documents.yaml
+++ b/.maestro/handbook/20-settings-legal-documents.yaml
@@ -39,3 +39,20 @@ appId: swiss.realunit.app
visible:
text: '.*Dokumente der Aktionariat AG.*'
timeout: 30000
+# Last tile is the 14.08 Teilnahmebedingungen (Ziff. 2–11). On a phone it can
+# sit below the fold, so scroll it into view, then restore Aktionariat so
+# flow 21 can tap that entry.
+- scrollUntilVisible:
+ element:
+ text: '.*Teilnahmebedingungen Referral-Programm.*'
+ direction: DOWN
+ timeout: 15000
+- extendedWaitUntil:
+ visible:
+ text: '.*Teilnahmebedingungen Referral-Programm.*'
+ timeout: 10000
+- scrollUntilVisible:
+ element:
+ text: '.*Dokumente der Aktionariat AG.*'
+ direction: UP
+ timeout: 15000
diff --git a/analysis_options.yaml b/analysis_options.yaml
index e9380ff99..85661df66 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -10,3 +10,9 @@ linter:
avoid_print: true
prefer_const_constructors: true
prefer_single_quotes: true
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - macos/**
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 0a1b23b87..d6e33b5cb 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -14,7 +14,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "swiss.realunit.app"
compileSdk = 36
- ndkVersion = "29.0.13113456 rc1"
+ ndkVersion = "29.0.13113456"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
@@ -64,3 +64,7 @@ android {
flutter {
source = "../.."
}
+
+dependencies {
+ implementation "com.android.installreferrer:installreferrer:2.2"
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 53960b220..d6b437275 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -18,4 +18,7 @@
# SumSub SDK general keep rules
-keep class com.sumsub.** { *; }
--dontwarn com.sumsub.**
+-dontwarn com.sumsub.**
+
+# Play Install Referrer (invite code across a fresh Android install)
+-keep class com.android.installreferrer.** { *; }
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f6981d..2aa4793da 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,32 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+ Verifizierte Aktionäre mit ausreichendem Bestand sehen auf dem Dashboard
+ die Karte Empfehlungen («Erhalte 20 REALU pro Weiterempfehlung»)
+ und denselben Einstieg in den Einstellungen. Sichtbarkeit, Mindestbestand
+ und Auszahlung entscheidet die DFX API; die App rendert
+ eligible 1:1. Nach einem fehlgeschlagenen ersten
+ Summary-GET (unmounted, 503, Timeout) blendet die Karte aus;
+ solange die Route aktuell und die App im Vordergrund ist, wird
+ die Gate erneut geladen, ohne eine bereits sichtbare Karte zu
+ verstecken. Ein 200 mit eligible: false wird nicht
+ gepollt.
+
+ Einladung erstellen: Name der eingeladenen Person (eine Zeile,
+ Zeilenumbrüche werden zu Leerzeichen, höchstens 80 Zeichen;
+ Absenden räumt den Feldtext auf; Autofokus), Checkbox
+ Ich habe die Teilnahmebedingungen gelesen und akzeptiert
+ (Checkbox erst nach geladenem TB-Text; Button erst danach aktiv;
+ der TB-Text ist markierbar), persönlicher Link von der API.
+ Der Einladungslink ist immer
+ https://realunit.app/invite/…
+ (http://, //realunit.app und
+ www.realunit.app werden auf den Apex gefaltet;
+ ein API-Pfad /promo/… bleibt Promo).
+ Nach dem Erstellen und in der Übersicht erscheint der personalisierte
+ Share-Text unter Persönlicher Einladungslink; Kopieren und
+ Versenden schicken denselben Text.
+ Nach der Annahme bleiben die Teilnahmebedingungen über den
+ Übersichtstitel und Einstellungen → Rechtliche Dokumente
+ (letzter Eintrag) lesbar. Kopieren bestätigt mit
+ Kopiert für zwei Sekunden nur nach erfolgreichem Schreiben
+ in die Zwischenablage und liest den Share-Text vor; ein erneutes
+ Tippen kopiert wieder.
+ Versenden setzt Titel und Betreff Persönlicher Einladungslink
+ und verankert das iPad/Mac-Share-Popover am Button (sonst am
+ Bildschirm). Kopiert-Status fällt zurück, wenn der Share-Text
+ wechselt. Übersicht zeigt Anzahl offen /
+ gutgeschrieben (vorgelesen «3 Offen») sowie Summe REALU und Frankenwert; offene Einladungen
+ lassen sich erneut kopieren und versenden, ohne den Status der
+ eingeladenen Person. Offene Einladungen verfallen nach 3 Monaten.
+ Kachel-Beschriftung
+ Aktienkurs. Jede Gutschrift in der Historie zeigt ganze REALU,
+ Datum und den im Zeitpunkt der Gutschrift fixierten Frankenwert
+ (eine Vorlese-Zeile: Titel, Datum, CHF, Anzahl) —
+ kein späteres Nachziehen auf den aktuellen Kurs.
+
+ Promo-Code und Einladungscode teilen dasselbe optionale
+ Registrierungs-Schritt (Überspringen erlaubt) und denselben Deep Link.
+ Während der Prüfung zeigt das Feld Code wird geprüft….
+ Nach der Prüfung erscheint bei einem Promo-Code der
+ Aktionstext in einem eigenen Fenster (nur Schließen, kein Tippen
+ daneben); bei einer Einladung die Erkennung
+ mit dem Namen des Empfehlers. Ein eingefügter
+ realunit.app/invite|promo/…-Link oder eine Share-Nachricht
+ wird auf den Code reduziert (Zero-Width- und Bidi-Zeichen aus
+ Messenger-Copy werden entfernt, auch im Play-Referrer und direkt
+ im Eingabefeld; Einfügen ignoriert eine Zwischenablage nur aus
+ Formatzeichen); das Feld hat Autofokus und fügt einen von der
+ Landing kopierten Code selbst ein, wenn es leer ist
+ (Deeplink-Stash geht vor). Weiter/Überspringen
+ sperren das Feld, bis sie durch sind, und brechen eine
+ ausstehende Code-Prüfung ab (Weiter wartet auf die Prüfung;
+ Überspringen setzt Weiter nicht auf Laden). Überspringen
+ verwirft einen getippten Code und lässt einen
+ Deeplink-Stash stehen. Ein abgelehnter Bind (4xx) zeigt
+ Link ungültig oder abgelaufen einmal in einem Dialog. 20 REALU als Promo-Zugabe nur beim ersten erfolgreichen
+ Kauf von mindestens N REALU (Vorgabe 200, durch die API gesetzt).
+ Einlösung und Kumulierungssperre sind Server-Pflicht. Universal Links:
+ https://realunit.app/invite/… und
+ /promo/… öffnen die App (auch auf www.realunit.app,
+ HTTP 200, kein 301). Nach Neuinstallation auf iOS den
+ Link erneut antippen oder den Code von der Landing-Seite
+ (App-Store-Tap kopiert ihn) bei der Registrierung einfügen. Android trägt den
+ Code als Play-Store-Install-Referrer, schon während die Landing
+ den Code prüft; ein ungültiger Lookup entfernt den Referrer.
+ Auf iOS führt der App-Store-Badge, auf Android Play. Die
+ In-App-CTA bleibt bis zum Lookup-Ende verborgen und gilt nur mobil.
+ Landing und Registrierung zeigen den Code in Grossbuchstaben
+ und höchstens 32 Zeichen (wie die API); ein angehängtes
+ Satzzeichen (`!`, `?`, `/`) fällt weg. Steckt
+ invite/ oder promo/ im Token,
+ bleibt nur der Code (wie die API). Unsichtbare
+ Steuerzeichen (ZWSP) und Vollbreite-Zeichen aus Messenger-URLs
+ entfernt die Landing-HTML schon in den Bytes, die Safari für
+ den Smart App Banner ausliest. Dasselbe gilt für Play/Ads-
+ utm_content=invite= und referrer auf
+ der Landing-URL, nicht nur für /invite/{code}
+ — auch ohne https:// (realunit.app/invite/…).
+ Facebook u=, Google q=, Outlook
+ url= und E-Mail link= auf der
+ Landing-URL ebenso, ein bloßes
+ u=hello oder eine fremde https://-URL
+ nicht. Ein Kampagnenname in utm_content verdeckt
+ keinen späteren Wrapper-Schlüssel, und eine Tracking-URL, die
+ nur realunit.app nennt, auch nicht. Einfügen und App
+ Links verwenden dieselben Schlüssel. Ein leeres oder fremdes
+ code= verdeckt kein späteres invite=.
+ Proofpoint URL Defense, Outlook Safe Links und kurze
+ ios-app://-Alternate-Links um eine
+ RealUnit-Landing werden entpackt.
+ Safari erkennt den Code und das Aktionsdatum nicht als Telefon- oder
+ Kalender-Link (format-detection
+ telephone=no, date=no;
+ x-apple-data-detectors="false" am Code und
+ Aktionstext, weil Lookup-JS den Text nachlädt).
+ Tab-Titel, Open-Graph, Twitter und die Standard-Beschreibung folgen
+ Laden / ungültig / nicht erreichbar; ein Pfad ohne Code gilt als
+ ungültig. Ungültige Überschrift und Text sind statusrot;
+ die Browser-Themenfarbe folgt (`#E02523` ungültig, sonst Markenblau).
+ og:locale:alternate nennt die andere Sprache (de_CH / en_GB).
+ Crawler lesen Smart-App-Banner app-argument,
+ og:image:alt / twitter:image:alt,
+ og:site_name (RealUnit),
+ Facebook App Links und Twitter App Cards aus den HTML-Bytes (ohne JS):
+ al:ios:url, al:android:url und
+ twitter:app:url:iphone / ipad /
+ googleplay sind
+ realunit-wallet://invite|promo/…,
+ al:android:class ist
+ swiss.realunit.app.MainActivity,
+ al:web:url die HTTPS-Landing,
+ twitter:app:country ist CH.
+ «Erneut versuchen» prüft den Code erneut ohne Seiten-Reload
+ (der Button wird vom Fehlertext beschrieben) und setzt den Fokus auf
+ «Code wird geprüft…»; nach dem Ergebnis auf die Ergebnis-Überschrift.
+ «Kopiert» erscheint nur, wenn Code oder Link wirklich in der
+ Zwischenablage liegt, und wird über den Status-Hinweis vorgelesen.
+
+ Automatisierte Oberflächen-Tests: Responsive-Matrix auf + Teilnahmebedingungen, Übersicht und Erstellseite, Cubit- und + API-Vertrags-Tests, Deeplink-Parser. Visual-Regression: Übersicht + (`276`), Registrierungs-Schritt (`277`), Dashboard-Karte (`278`), + Nicht-berechtigt (`279`), Einladung erstellt (`280`), + Teilnahmebedingungen (`281`), Prämie in der Historie (`282`), + TB nach Annahme (`283`), Namenseingabe (`284`), Einstellungen-Karte + (`285`), Rechtliche Dokumente letzter Eintrag (`286`), Einladung + erkannt (`287`), Historie mit Prämie (`288`), Dashboard-Einstieg + (`289`), Promo-Aktionstext (`290`). +
+
+
+
+ eligible setzt.
+
+
+
+
+
+
+
+ eligible setzt. Kartentext «Erhalte 20 REALU pro
+ Weiterempfehlung».
+
+
+
+
+
+ realunit.hbs: der Mailer sendet HTML aus dem Klartext;
+ GET /admin/emails bleibt der Klartext.