From 1fb95147fff17191b4d536bfa0a08ad10ca8b80f Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Mon, 24 Aug 2026 23:10:54 +0200
Subject: [PATCH] feat: add referral and promo programme screens
Invite creation, terms acceptance, deeplink code stash, payout rows with
CHF frozen at credit, and store App Links for realunit.app/invite.
---
android/app/src/main/AndroidManifest.xml | 12 +
assets/languages/strings_de.arb | 22 +-
assets/languages/strings_en.arb | 22 +-
assets/legal/referral_terms_de.md | 47 +++
assets/legal/referral_terms_en.md | 47 +++
ios/Runner/Runner.entitlements | 4 +
lib/models/transaction.dart | 11 +-
.../dto/referral_bind_result_dto.dart | 44 +++
.../dto/referral_created_invite_dto.dart | 24 ++
.../referral/dto/referral_invite_dto.dart | 34 +++
.../referral/dto/referral_payout_dto.dart | 33 ++
.../referral/dto/referral_summary_dto.dart | 34 +++
.../dfx/real_unit_referral_service.dart | 133 ++++++++
.../service/referral_payout_merger.dart | 47 +++
.../service/transaction_history_service.dart | 151 ++++++---
lib/screens/dashboard/dashboard_page.dart | 2 +
.../dashboard/widgets/transaction_row.dart | 80 +++++
.../registration/kyc_registration_page.dart | 20 ++
.../steps/kyc_registration_personal_step.dart | 9 +
.../referral/cubit/referral_cubit.dart | 115 +++++++
.../cubit/referral_eligibility_cubit.dart | 24 ++
.../cubit/referral_eligibility_state.dart | 25 ++
.../referral/cubit/referral_state.dart | 98 ++++++
.../referral/referral_create_page.dart | 206 +++++++++++++
.../referral/referral_overview_page.dart | 190 ++++++++++++
lib/screens/referral/referral_page.dart | 85 ++++++
lib/screens/referral/referral_terms_page.dart | 111 +++++++
.../referral/widgets/referral_entry_card.dart | 51 ++++
lib/screens/settings/settings_page.dart | 286 ++++++++++--------
.../widgets/transaction_history_row.dart | 69 +++--
lib/setup/di.dart | 4 +
lib/setup/routing/boot_navigation.dart | 7 +
lib/setup/routing/referral_bind.dart | 63 ++++
lib/setup/routing/referral_pending_code.dart | 59 ++++
lib/setup/routing/router_config.dart | 14 +
lib/setup/routing/routes/app_link_entry.dart | 114 ++++++-
lib/setup/routing/routes/settings_routes.dart | 2 +
pubspec.lock | 16 +
pubspec.yaml | 1 +
.../service/referral_payout_merger_test.dart | 75 +++++
test/setup/routing/app_link_entry_test.dart | 252 ++++++++-------
41 files changed, 2337 insertions(+), 306 deletions(-)
create mode 100644 assets/legal/referral_terms_de.md
create mode 100644 assets/legal/referral_terms_en.md
create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart
create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart
create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_invite_dto.dart
create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_payout_dto.dart
create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_summary_dto.dart
create mode 100644 lib/packages/service/dfx/real_unit_referral_service.dart
create mode 100644 lib/packages/service/referral_payout_merger.dart
create mode 100644 lib/screens/referral/cubit/referral_cubit.dart
create mode 100644 lib/screens/referral/cubit/referral_eligibility_cubit.dart
create mode 100644 lib/screens/referral/cubit/referral_eligibility_state.dart
create mode 100644 lib/screens/referral/cubit/referral_state.dart
create mode 100644 lib/screens/referral/referral_create_page.dart
create mode 100644 lib/screens/referral/referral_overview_page.dart
create mode 100644 lib/screens/referral/referral_page.dart
create mode 100644 lib/screens/referral/referral_terms_page.dart
create mode 100644 lib/screens/referral/widgets/referral_entry_card.dart
create mode 100644 lib/setup/routing/referral_bind.dart
create mode 100644 lib/setup/routing/referral_pending_code.dart
create mode 100644 test/packages/service/referral_payout_merger_test.dart
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a07818b21..bded37947 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -54,6 +54,18 @@
+
+
+
+
+
+
+
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index 125f65959..ac3db2565 100644
--- a/assets/languages/strings_de.arb
+++ b/assets/languages/strings_de.arb
@@ -264,6 +264,26 @@
"receiver": "Empfänger",
"recoveryWords": "Wiederherstellungs-Wörter",
"recoveryWordsInvalid": "Wiederherstellungs-Wörter ungültig",
+ "referralCodeHint": "Einladungs- oder Promo-Code",
+ "referralCodeOptional": "Einladungs- oder Promo-Code (optional)",
+ "referralCopyInviteLink": "Einladungslink kopieren",
+ "referralCreateInvite": "Einladungslink erstellen",
+ "referralCreateInviteDescription": "Gib den Vornamen der Person ein, die du einladen möchtest.",
+ "referralGuestName": "Name der eingeladenen Person",
+ "referralInviteUrlLabel": "Persönlicher Einladungslink",
+ "referralNotEligible": "Das Empfehlungsprogramm steht verifizierten Aktionären mit dem erforderlichen Bestand zur Verfügung.",
+ "referralPayout": "Empfehlungsprämie",
+ "referralPayoutChf": "CHF ${chf} zum Zeitpunkt der Gutschrift",
+ "referralPromoTitle": "Aktion",
+ "referrals": "Empfehlungen",
+ "referralShareInviteLink": "Einladungslink versenden",
+ "referralSharePrice": "Aktienkurs",
+ "referralShareText": "Hey ${guestName}, ${hostName} lädt dich ein zu RealUnit: ${url}",
+ "referralsSubtitle": "Erhalte 20 REALU pro Weiterempfehlung",
+ "referralStatusCredited": "Gutgeschrieben",
+ "referralStatusOpen": "Offen",
+ "referralTermsCheckbox": "Ich habe die Teilnahmebedingungen gelesen und akzeptiert",
+ "referralTotalReceived": "Insgesamt erhalten",
"refresh": "Aktualisieren",
"registerAccountType": "Kontotyp",
"registerCitizenship": "Staatsangehörigkeit",
@@ -466,4 +486,4 @@
"youPay": "Sie bezahlen",
"youReceive": "Sie erhalten",
"youSell": "Sie verkaufen"
-}
\ No newline at end of file
+}
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 651fd68c9..878c75e28 100644
--- a/assets/languages/strings_en.arb
+++ b/assets/languages/strings_en.arb
@@ -264,6 +264,26 @@
"receiver": "Receiver",
"recoveryWords": "recovery words",
"recoveryWordsInvalid": "Recovery words invalid",
+ "referralCodeHint": "Invite or promo code",
+ "referralCodeOptional": "Invite or promo code (optional)",
+ "referralCopyInviteLink": "Copy invite link",
+ "referralCreateInvite": "Create invite link",
+ "referralCreateInviteDescription": "Enter the first name of the person you want to invite.",
+ "referralGuestName": "Name of the invited person",
+ "referralInviteUrlLabel": "Personal invite link",
+ "referralNotEligible": "The referral programme is available once you are a verified shareholder with the required holding.",
+ "referralPayout": "Referral prize",
+ "referralPayoutChf": "CHF ${chf} at credit",
+ "referralPromoTitle": "Promotion",
+ "referrals": "Referrals",
+ "referralShareInviteLink": "Share invite link",
+ "referralSharePrice": "Share price",
+ "referralShareText": "Hey ${guestName}, ${hostName} is inviting you to RealUnit: ${url}",
+ "referralsSubtitle": "Receive 20 REALU per referral",
+ "referralStatusCredited": "Credited",
+ "referralStatusOpen": "Open",
+ "referralTermsCheckbox": "I have read and accepted the terms of participation",
+ "referralTotalReceived": "Total received",
"refresh": "Refresh",
"registerAccountType": "Account type",
"registerCitizenship": "Citizenship",
@@ -466,4 +486,4 @@
"youPay": "You pay",
"youReceive": "You receive",
"youSell": "You sell"
-}
\ No newline at end of file
+}
diff --git a/assets/legal/referral_terms_de.md b/assets/legal/referral_terms_de.md
new file mode 100644
index 000000000..e67564934
--- /dev/null
+++ b/assets/legal/referral_terms_de.md
@@ -0,0 +1,47 @@
+# Teilnahmebedingungen Referral-Programm
+
+*Stand: 14.08.2026*
+
+## 1. Veranstalterin
+
+RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. Das Referral-Programm wird in der RealUnit App angeboten.
+
+## 2. Teilnahmeberechtigung
+
+Teilnahmeberechtigt sind Nutzerinnen und Nutzer der RealUnit App, die mindestens 70 RealUnit-Aktientoken halten. Massgeblich für die Teilnahmeberechtigung ist der Stand bei Erstellung der Einladung und die Qualifikation nach Ziff. 4. Mitarbeitende und Organe der RealUnit Schweiz AG sowie mit ihr verbundene Unternehmen sind von der Teilnahme ausgeschlossen.
+
+## 3. Ablauf
+
+Die teilnehmende Person erstellt in der App einen persönlichen Einladungslink für eine namentlich genannte Person. Die eingeladene Person darf noch nicht in der App registriert sein.
+
+## 4. Prämie
+
+Für jede erfolgreiche Empfehlung erhält die teilnehmende Person 20 RealUnit-Aktientoken. Erfolgreich ist eine Empfehlung, wenn die eingeladene Person sich registriert und einen ersten Kauf von RealUnit-Aktientoken tätigt. Der Frankenwert der Gutschrift wird in der Transaktionshistorie ausgewiesen. Die Prämie ist Entgelt für die Vermittlung.
+
+## 5. Obergrenze
+
+Es werden höchstens einhundert erfolgreiche Empfehlungen innerhalb eines Quartals vergütet.
+
+## 6. Steuern
+
+Die Prämie stellt steuerbares Einkommen dar. Die Anzahl, der Frankenwert und das Datum der Gutschrift sind in der App ausgewiesen. RealUnit erteilt keine Steuerauskunft.
+
+## 7. Datenschutz
+
+Die empfehlende Person sieht nicht Registrierung, Verifizierung oder Käufe der eingeladenen Person. Offene Einladungen werden nach drei Monaten gelöscht.
+
+## 8. Missbrauch
+
+Selbstempfehlung, die Nutzung mehrfacher Identitäten und koordinierte Anwerbung sind untersagt. Bei Missbrauch kann RealUnit Prämien verweigern oder zurückfordern und die Teilnahme beenden.
+
+## 9. Werbung
+
+Für die Einladung darf nur der in der App bereitgestellte Einladungslink verwendet werden.
+
+## 10. Dauer, Änderung und Beendigung
+
+RealUnit kann das Programm jederzeit ändern oder beenden. Bereits entstandene Ansprüche bleiben bestehen.
+
+## 11. Schweizer Recht, Gerichtsstand Baar
+
+Es gilt Schweizer Recht. Gerichtsstand ist Baar.
diff --git a/assets/legal/referral_terms_en.md b/assets/legal/referral_terms_en.md
new file mode 100644
index 000000000..dd60a9c22
--- /dev/null
+++ b/assets/legal/referral_terms_en.md
@@ -0,0 +1,47 @@
+# Terms of Participation — Referral Programme
+
+*As of: 14 August 2026*
+
+## 1. Organiser
+
+RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. The referral programme is offered in the RealUnit app.
+
+## 2. Eligibility
+
+Eligible are users of the RealUnit app who hold at least 70 RealUnit share tokens. Eligibility is determined at the time the invitation is created and by the qualification under section 4. Employees and officers of RealUnit Schweiz AG and of companies affiliated with it are excluded from participation.
+
+## 3. Process
+
+The participant creates a personal invitation link in the app for a named person. The invited person must not already be registered in the app.
+
+## 4. Reward
+
+For each successful referral the participant receives 20 RealUnit share tokens. A referral is successful when the invited person registers and makes a first purchase of RealUnit share tokens. The Swiss-franc value of the credit is shown in the transaction history. The reward is remuneration for the introduction.
+
+## 5. Cap
+
+At most one hundred successful referrals are remunerated within a calendar quarter.
+
+## 6. Taxes
+
+The reward constitutes taxable income. The quantity, Swiss-franc value and date of the credit are shown in the app. RealUnit does not provide tax advice.
+
+## 7. Data protection
+
+The referrer does not see the invitee's registration, verification or purchases. Open invitations are deleted after three months.
+
+## 8. Abuse
+
+Self-referral, use of multiple identities and coordinated solicitation are prohibited. In case of abuse RealUnit may refuse or claw back rewards and terminate participation.
+
+## 9. Advertising
+
+Only the invitation link provided in the app may be used for invitations.
+
+## 10. Duration, changes and termination
+
+RealUnit may change or end the programme at any time. Claims that have already arisen remain unaffected.
+
+## 11. Swiss law, place of jurisdiction Baar
+
+Swiss law applies. Place of jurisdiction is Baar.
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 44c362266..9f80215c3 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -2,6 +2,10 @@
+ com.apple.developer.associated-domains
+
+ applinks:realunit.app
+
keychain-access-groups
$(AppIdentifierPrefix)$(CFBundleIdentifier)
diff --git a/lib/models/transaction.dart b/lib/models/transaction.dart
index 0b5b247ae..dd4cf83cc 100644
--- a/lib/models/transaction.dart
+++ b/lib/models/transaction.dart
@@ -1,7 +1,16 @@
import 'package:realunit_wallet/models/asset.dart';
import 'package:realunit_wallet/packages/service/transaction_history_service.dart';
-enum TransactionTypes { transfer, genericContractCall, tokenTransfer, savingsAdd, savingsRemove }
+enum TransactionTypes {
+ transfer,
+ genericContractCall,
+ tokenTransfer,
+ savingsAdd,
+ savingsRemove,
+ /// Referral/promo payout credited via the API. [Transaction.data] holds the
+ /// CHF value frozen at credit (decimal string); never recompute from price.
+ referralPayout,
+}
/// Transaction with on-chain metadata
class Transaction {
diff --git a/lib/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart b/lib/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart
new file mode 100644
index 000000000..5a4818064
--- /dev/null
+++ b/lib/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart
@@ -0,0 +1,44 @@
+/// Response from `POST /v1/realunit/referral/bind`.
+/// For `kind == Promo`, [campaignText] / [campaignTextEn] carry the prescribed
+/// wording from the API — do not compose campaign copy in the app.
+class ReferralBindResultDto {
+ final String kind;
+ final String? campaignText;
+ final String? campaignTextEn;
+ final num? minBuyRealu;
+ final DateTime? validUntil;
+ final num? redemptionCap;
+
+ const ReferralBindResultDto({
+ required this.kind,
+ this.campaignText,
+ this.campaignTextEn,
+ this.minBuyRealu,
+ this.validUntil,
+ this.redemptionCap,
+ });
+
+ bool get isPromo => kind == 'Promo';
+ bool get isInvite => kind == 'Invite';
+
+ /// Locale-aware campaign wording. EN falls back to DE when the EN field is absent.
+ String? campaignTextForLocale(String languageCode) {
+ if (languageCode == 'en') {
+ return campaignTextEn ?? campaignText;
+ }
+ return campaignText ?? campaignTextEn;
+ }
+
+ factory ReferralBindResultDto.fromJson(Map json) {
+ return ReferralBindResultDto(
+ kind: json['kind'] as String,
+ campaignText: json['campaignText'] as String?,
+ campaignTextEn: json['campaignTextEn'] as String?,
+ minBuyRealu: json['minBuyRealu'] as num?,
+ validUntil: json['validUntil'] != null
+ ? DateTime.parse(json['validUntil'] as String)
+ : null,
+ redemptionCap: json['redemptionCap'] as num?,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart b/lib/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart
new file mode 100644
index 000000000..3d23168e9
--- /dev/null
+++ b/lib/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart
@@ -0,0 +1,24 @@
+/// Response from `POST /v1/realunit/referral/invites`.
+/// `copyText` is a German-only API fallback; prefer i18n share text when available.
+class ReferralCreatedInviteDto {
+ final String code;
+ final String url;
+ final String guestName;
+ final String? copyText;
+
+ const ReferralCreatedInviteDto({
+ required this.code,
+ required this.url,
+ required this.guestName,
+ this.copyText,
+ });
+
+ factory ReferralCreatedInviteDto.fromJson(Map json) {
+ return ReferralCreatedInviteDto(
+ code: json['code'] as String,
+ url: json['url'] as String,
+ guestName: json['guestName'] as String,
+ copyText: json['copyText'] as String?,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/referral/dto/referral_invite_dto.dart b/lib/packages/service/dfx/models/referral/dto/referral_invite_dto.dart
new file mode 100644
index 000000000..0e715a848
--- /dev/null
+++ b/lib/packages/service/dfx/models/referral/dto/referral_invite_dto.dart
@@ -0,0 +1,34 @@
+/// One invite row from `GET /v1/realunit/referral/invites`.
+/// Status values from the API: `Open` / `Bound` / `Credited` / `Deleted`.
+/// The UI only surfaces Open as "offen" and Credited as "gutgeschrieben".
+class ReferralInviteDto {
+ final int id;
+ final String code;
+ final String url;
+ final String guestName;
+ final String status;
+ final DateTime created;
+
+ const ReferralInviteDto({
+ required this.id,
+ required this.code,
+ required this.url,
+ required this.guestName,
+ required this.status,
+ required this.created,
+ });
+
+ bool get isOpen => status == 'Open';
+ bool get isCredited => status == 'Credited';
+
+ factory ReferralInviteDto.fromJson(Map json) {
+ return ReferralInviteDto(
+ id: (json['id'] as num).toInt(),
+ code: json['code'] as String,
+ url: json['url'] as String,
+ guestName: json['guestName'] as String,
+ status: json['status'] as String,
+ created: DateTime.parse(json['created'] as String),
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/referral/dto/referral_payout_dto.dart b/lib/packages/service/dfx/models/referral/dto/referral_payout_dto.dart
new file mode 100644
index 000000000..079f17441
--- /dev/null
+++ b/lib/packages/service/dfx/models/referral/dto/referral_payout_dto.dart
@@ -0,0 +1,33 @@
+/// One referral payout row from `GET /v1/realunit/referral/payouts`.
+/// [chfValue] is the CHF amount frozen at credit — never recompute from current price.
+class ReferralPayoutDto {
+ final int id;
+ final num amount;
+ final num chfValue;
+ final DateTime created;
+ final String kind;
+ final String status;
+ final String? txHash;
+
+ const ReferralPayoutDto({
+ required this.id,
+ required this.amount,
+ required this.chfValue,
+ required this.created,
+ required this.kind,
+ required this.status,
+ this.txHash,
+ });
+
+ factory ReferralPayoutDto.fromJson(Map json) {
+ return ReferralPayoutDto(
+ id: (json['id'] as num).toInt(),
+ amount: json['amount'] as num,
+ chfValue: json['chfValue'] as num,
+ created: DateTime.parse(json['created'] as String),
+ kind: json['kind'] as String,
+ status: json['status'] as String,
+ txHash: json['txHash'] as String?,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/models/referral/dto/referral_summary_dto.dart b/lib/packages/service/dfx/models/referral/dto/referral_summary_dto.dart
new file mode 100644
index 000000000..9cf3e71ef
--- /dev/null
+++ b/lib/packages/service/dfx/models/referral/dto/referral_summary_dto.dart
@@ -0,0 +1,34 @@
+/// Server-side referral programme summary for the current wallet
+/// (`GET /v1/realunit/referral/summary`). `eligible` is the authoritative
+/// entry gate — see CONTRIBUTING.md "API as Decision Authority".
+class ReferralSummaryDto {
+ final bool eligible;
+ final bool termsAccepted;
+ final num? minHolding;
+ final int openCount;
+ final int creditedCount;
+ final num realuSum;
+ final num chfSum;
+
+ const ReferralSummaryDto({
+ required this.eligible,
+ required this.termsAccepted,
+ this.minHolding,
+ required this.openCount,
+ required this.creditedCount,
+ required this.realuSum,
+ required this.chfSum,
+ });
+
+ factory ReferralSummaryDto.fromJson(Map json) {
+ return ReferralSummaryDto(
+ eligible: json['eligible'] as bool,
+ termsAccepted: json['termsAccepted'] as bool,
+ minHolding: json['minHolding'] as num?,
+ openCount: (json['openCount'] as num).toInt(),
+ creditedCount: (json['creditedCount'] as num).toInt(),
+ realuSum: json['realuSum'] as num,
+ chfSum: json['chfSum'] as num,
+ );
+ }
+}
diff --git a/lib/packages/service/dfx/real_unit_referral_service.dart b/lib/packages/service/dfx/real_unit_referral_service.dart
new file mode 100644
index 000000000..212697636
--- /dev/null
+++ b/lib/packages/service/dfx/real_unit_referral_service.dart
@@ -0,0 +1,133 @@
+import 'dart:convert';
+
+import 'package:realunit_wallet/packages/config/api_config.dart';
+import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_invite_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_payout_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_summary_dto.dart';
+
+class RealUnitReferralService extends DFXAuthService {
+ RealUnitReferralService(super.appStore, super.walletService);
+
+ static const _basePath = '/v1/realunit/referral';
+
+ Future acceptTerms() async {
+ final uri = buildUri(host, '$_basePath/terms/accept');
+ final response = await authenticatedPost(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode({'accepted': true}),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+ }
+
+ Future getSummary() async {
+ final uri = buildUri(host, '$_basePath/summary');
+ final response = await authenticatedGet(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+
+ if (response.statusCode != 200) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+
+ return ReferralSummaryDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future createInvite({
+ required String guestName,
+ }) async {
+ final uri = buildUri(host, '$_basePath/invites');
+ final response = await authenticatedPost(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode({'guestName': guestName}),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+
+ return ReferralCreatedInviteDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future> getInvites() async {
+ final uri = buildUri(host, '$_basePath/invites');
+ final response = await authenticatedGet(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+
+ if (response.statusCode != 200) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+
+ final list = jsonDecode(response.body) as List;
+ return list
+ .map((e) => ReferralInviteDto.fromJson(e as Map))
+ .toList();
+ }
+
+ Future bind({required String code}) async {
+ final uri = buildUri(host, '$_basePath/bind');
+ final response = await authenticatedPost(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ body: jsonEncode({'code': code}),
+ );
+
+ if (response.statusCode != 200 && response.statusCode != 201) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+
+ return ReferralBindResultDto.fromJson(
+ jsonDecode(response.body) as Map,
+ );
+ }
+
+ Future> getPayouts() async {
+ final uri = buildUri(host, '$_basePath/payouts');
+ final response = await authenticatedGet(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+
+ if (response.statusCode != 200) {
+ throw ApiException.fromBody(
+ response.body,
+ httpStatusCode: response.statusCode,
+ );
+ }
+
+ final list = jsonDecode(response.body) as List;
+ return list
+ .map((e) => ReferralPayoutDto.fromJson(e as Map))
+ .toList();
+ }
+}
diff --git a/lib/packages/service/referral_payout_merger.dart b/lib/packages/service/referral_payout_merger.dart
new file mode 100644
index 000000000..b6a628396
--- /dev/null
+++ b/lib/packages/service/referral_payout_merger.dart
@@ -0,0 +1,47 @@
+import 'package:realunit_wallet/models/asset.dart';
+import 'package:realunit_wallet/models/transaction.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_payout_dto.dart';
+
+/// Converts referral payouts into [Transaction] rows and drops on-chain
+/// transfers that share a `txHash` with a payout (dedupe — payout wins).
+List mergeReferralPayouts({
+ required List onChain,
+ required List payouts,
+ required Asset asset,
+ required String walletAddress,
+}) {
+ final payoutTxHashes = {};
+ final payoutTxs = [];
+
+ for (final payout in payouts) {
+ final hash = payout.txHash;
+ if (hash != null && hash.isNotEmpty) {
+ payoutTxHashes.add(hash.toLowerCase());
+ }
+ payoutTxs.add(
+ Transaction(
+ height: 0,
+ txId: hash?.isNotEmpty == true ? hash! : 'referral-payout-${payout.id}',
+ chainId: asset.chainId,
+ senderAddress: '',
+ receiverAddress: walletAddress,
+ amount: BigInt.from(payout.amount.round()),
+ asset: asset,
+ type: TransactionTypes.referralPayout,
+ note: '',
+ // CHF frozen at credit — display only; never recompute from price.
+ data: payout.chfValue.toString(),
+ timestamp: payout.created,
+ ),
+ );
+ }
+
+ final filteredOnChain = onChain.where((tx) {
+ if (payoutTxHashes.isEmpty) return true;
+ return !payoutTxHashes.contains(tx.txId.toLowerCase());
+ });
+
+ final merged = [...filteredOnChain, ...payoutTxs]
+ ..sort((a, b) => b.timestamp.compareTo(a.timestamp));
+ return merged;
+}
diff --git a/lib/packages/service/transaction_history_service.dart b/lib/packages/service/transaction_history_service.dart
index 2212fa5d9..9fb91a80b 100644
--- a/lib/packages/service/transaction_history_service.dart
+++ b/lib/packages/service/transaction_history_service.dart
@@ -7,16 +7,22 @@ import 'package:realunit_wallet/packages/config/api_config.dart';
import 'package:realunit_wallet/packages/repository/transaction_repository.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart';
import 'package:realunit_wallet/packages/service/dfx/models/history/dto/account_history_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_payout_dto.dart';
import 'package:realunit_wallet/packages/service/dfx/models/transactions/dto/transactions_dto.dart';
import 'package:web3dart/credentials.dart';
class TransactionHistoryService extends DFXAuthService {
- static String _accountHistoryPath(String address) => '/v1/realunit/account/$address/history';
+ static String _accountHistoryPath(String address) =>
+ '/v1/realunit/account/$address/history';
static const String _transactionsPath = 'v1/transaction';
final TransactionRepository _transactionRepository;
- TransactionHistoryService(super.appStore, super.walletService, this._transactionRepository);
+ TransactionHistoryService(
+ super.appStore,
+ super.walletService,
+ this._transactionRepository,
+ );
Future apiBasedSync() async {
final results = await Future.wait([
@@ -27,63 +33,110 @@ class TransactionHistoryService extends DFXAuthService {
final accountHistory = results.elementAt(0) as AccountHistoryDto?;
final transactions = results.elementAt(1) as List;
- if (accountHistory == null) return;
+ if (accountHistory != null) {
+ for (final entry in accountHistory.history) {
+ final transfer = entry.transfer;
+ if (transfer == null) continue;
- for (final entry in accountHistory.history) {
- final transfer = entry.transfer;
- if (transfer == null) continue;
-
- final txId = entry.txHash;
- final exists = await _transactionRepository.existsTransaction(txId);
- final matchingTransaction = transactions.firstWhereOrNull(
- (t) => t.inputTxId == txId || t.outputTxId == txId,
- );
-
- if (matchingTransaction != null && matchingTransaction.id != null) {
- final dfxTransaction = DfxTransaction(
- dfxId: matchingTransaction.id!,
- rate: matchingTransaction.rate,
- inputTxId: matchingTransaction.inputTxId,
- outputTxId: matchingTransaction.outputTxId,
- height: 0, // TODO
- txId: txId,
- chainId: appStore.apiConfig.asset.chainId,
- senderAddress: transfer.from,
- receiverAddress: transfer.to,
- amount: BigInt.parse(transfer.value),
- asset: appStore.apiConfig.asset,
- type: TransactionTypes.tokenTransfer,
- note: '',
- data: null,
- timestamp: entry.timestamp,
+ final txId = entry.txHash;
+ final exists = await _transactionRepository.existsTransaction(txId);
+ final matchingTransaction = transactions.firstWhereOrNull(
+ (t) => t.inputTxId == txId || t.outputTxId == txId,
);
- if (exists) {
- await _transactionRepository.updateDfxTransaction(dfxTransaction);
+ if (matchingTransaction != null && matchingTransaction.id != null) {
+ final dfxTransaction = DfxTransaction(
+ dfxId: matchingTransaction.id!,
+ rate: matchingTransaction.rate,
+ inputTxId: matchingTransaction.inputTxId,
+ outputTxId: matchingTransaction.outputTxId,
+ height: 0, // TODO
+ txId: txId,
+ chainId: appStore.apiConfig.asset.chainId,
+ senderAddress: transfer.from,
+ receiverAddress: transfer.to,
+ amount: BigInt.parse(transfer.value),
+ asset: appStore.apiConfig.asset,
+ type: TransactionTypes.tokenTransfer,
+ note: '',
+ data: null,
+ timestamp: entry.timestamp,
+ );
+
+ if (exists) {
+ await _transactionRepository.updateDfxTransaction(dfxTransaction);
+ } else {
+ await _transactionRepository.insertDfxTransaction(dfxTransaction);
+ }
} else {
- await _transactionRepository.insertDfxTransaction(dfxTransaction);
+ final transaction = Transaction(
+ height: 0, // TODO
+ txId: txId,
+ chainId: appStore.apiConfig.asset.chainId,
+ senderAddress: transfer.from,
+ receiverAddress: transfer.to,
+ amount: BigInt.parse(transfer.value),
+ asset: appStore.apiConfig.asset,
+ type: TransactionTypes.tokenTransfer,
+ note: '',
+ data: null,
+ timestamp: entry.timestamp,
+ );
+
+ if (exists) {
+ await _transactionRepository.updateTransaction(transaction);
+ } else {
+ await _transactionRepository.insertTransaction(transaction);
+ }
}
- } else {
+ }
+ }
+
+ await _syncReferralPayouts();
+ }
+
+ Future _syncReferralPayouts() async {
+ try {
+ final uri = buildUri(host, '/v1/realunit/referral/payouts');
+ final response = await authenticatedGet(
+ uri,
+ headers: {'Content-Type': 'application/json'},
+ );
+ if (response.statusCode != 200) return;
+
+ final decoded = jsonDecode(response.body);
+ if (decoded is! List) return;
+
+ final asset = appStore.apiConfig.asset;
+ final walletAddress = appStore.primaryAddress;
+ for (final raw in decoded) {
+ if (raw is! Map) continue;
+ final payout = ReferralPayoutDto.fromJson(raw);
+ final hash = payout.txHash;
+ final txId = (hash != null && hash.isNotEmpty)
+ ? hash
+ : 'referral-payout-${payout.id}';
final transaction = Transaction(
- height: 0, // TODO
+ height: 0,
txId: txId,
- chainId: appStore.apiConfig.asset.chainId,
- senderAddress: transfer.from,
- receiverAddress: transfer.to,
- amount: BigInt.parse(transfer.value),
- asset: appStore.apiConfig.asset,
- type: TransactionTypes.tokenTransfer,
+ chainId: asset.chainId,
+ senderAddress: '',
+ receiverAddress: walletAddress,
+ amount: BigInt.from(payout.amount.round()),
+ asset: asset,
+ type: TransactionTypes.referralPayout,
note: '',
- data: null,
- timestamp: entry.timestamp,
+ data: payout.chfValue.toString(),
+ timestamp: payout.created,
);
-
- if (exists) {
+ if (await _transactionRepository.existsTransaction(txId)) {
await _transactionRepository.updateTransaction(transaction);
} else {
await _transactionRepository.insertTransaction(transaction);
}
}
+ } catch (_) {
+ return;
}
}
@@ -106,7 +159,9 @@ class TransactionHistoryService extends DFXAuthService {
if (response.statusCode != 200) return [];
final List json = jsonDecode(response.body);
- return json.map((e) => TransactionDto.fromJson(e as Map)).toList();
+ return json
+ .map((e) => TransactionDto.fromJson(e as Map))
+ .toList();
}
Future> fetchPendingTransactions() async {
@@ -121,7 +176,9 @@ class TransactionHistoryService extends DFXAuthService {
.toList();
final walletAddress = appStore.primaryAddress;
- return transactions.where((t) => t.isPending && t.belongsToWallet(walletAddress)).toList();
+ return transactions
+ .where((t) => t.isPending && t.belongsToWallet(walletAddress))
+ .toList();
}
}
diff --git a/lib/screens/dashboard/dashboard_page.dart b/lib/screens/dashboard/dashboard_page.dart
index 1fc5af521..a9b7f08a9 100644
--- a/lib/screens/dashboard/dashboard_page.dart
+++ b/lib/screens/dashboard/dashboard_page.dart
@@ -17,6 +17,7 @@ import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_por
import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_portfolio_chart_widget.dart';
import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_price_widget.dart';
import 'package:realunit_wallet/screens/dashboard/widgets/sections/dashboard_transaction_history.dart';
+import 'package:realunit_wallet/screens/referral/widgets/referral_entry_card.dart';
import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart';
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
@@ -137,6 +138,7 @@ class DashboardView extends StatelessWidget {
spacing: 20.0,
children: [
const DashboardActions(),
+ const ReferralEntryCard(),
DashboardPortfolio(
price: dashboardState.price,
),
diff --git a/lib/screens/dashboard/widgets/transaction_row.dart b/lib/screens/dashboard/widgets/transaction_row.dart
index cb2b412c4..b3660070c 100644
--- a/lib/screens/dashboard/widgets/transaction_row.dart
+++ b/lib/screens/dashboard/widgets/transaction_row.dart
@@ -33,6 +33,8 @@ class TransactionRow extends StatelessWidget {
secondRowTextColor: secondRowTextColor,
showBlockchainIcon: showBlockchainIcon,
)
+ : transaction.type == TransactionTypes.referralPayout
+ ? ReferralPayoutTransactionRow(transaction: transaction)
: InkWell(
child: Container(
decoration: BoxDecoration(
@@ -118,6 +120,84 @@ class TransactionRow extends StatelessWidget {
);
}
+class ReferralPayoutTransactionRow extends StatelessWidget {
+ final Transaction transaction;
+
+ const ReferralPayoutTransactionRow({super.key, required this.transaction});
+
+ @override
+ Widget build(BuildContext context) {
+ final chf = transaction.data;
+ return InkWell(
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(20),
+ color: RealUnitColors.basic.white,
+ ),
+ child: Row(
+ spacing: 10.0,
+ children: [
+ Container(
+ height: 32,
+ width: 32,
+ decoration: BoxDecoration(
+ color: RealUnitColors.brand200,
+ borderRadius: BorderRadius.circular(24.0),
+ ),
+ child: const Icon(Icons.add, color: RealUnitColors.darkBlue),
+ ),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ S.of(context).referralPayout,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ height: 20 / 16,
+ ),
+ ),
+ Text(
+ DateFormat('MMM dd, yyyy | H:mm').format(transaction.timestamp.toLocal()),
+ style: const TextStyle(
+ fontSize: 12,
+ height: 16 / 12,
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ if (chf != null && chf.isNotEmpty)
+ Text(
+ S.of(context).referralPayoutChf(chf),
+ style: const TextStyle(
+ fontSize: 12,
+ height: 16 / 12,
+ color: RealUnitColors.neutral500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ HideAmountText(
+ leadingSymbol: '+',
+ amount: transaction.amount,
+ decimals: transaction.asset.decimals,
+ fractionalDigits: 0,
+ trimZeros: false,
+ trailingSymbol: transaction.asset.symbol,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ height: 20 / 16,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
class SavingsTransactionRow extends StatelessWidget {
final Transaction transaction;
final Color firstRowTextColor;
diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart
index 4bc984ba7..c8ac2ba9d 100644
--- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart
+++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart
@@ -26,6 +26,7 @@ import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registr
import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart';
import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_tax_step.dart';
import 'package:realunit_wallet/setup/di.dart';
+import 'package:realunit_wallet/setup/routing/referral_pending_code.dart';
import 'package:realunit_wallet/styles/colors.dart';
class KycRegistrationPage extends StatelessWidget {
@@ -76,6 +77,7 @@ class _KycRegistrationViewState extends State {
final phoneCtrl = ValueNotifier(null);
final nationalityCtrl = ValueNotifier(null);
final birthdayCtrl = ValueNotifier(null);
+ final referralCodeCtrl = TextEditingController();
final addressStreetCtrl = TextEditingController();
final addressStreetNumberCtrl = TextEditingController();
@@ -98,6 +100,14 @@ class _KycRegistrationViewState extends State {
);
});
+ // Prefill a deeplink-stashed invite/promo code (same field for both).
+ unawaited(
+ peekPendingReferralCode().then((code) {
+ if (!mounted || code == null || code.isEmpty) return;
+ if (referralCodeCtrl.text.isEmpty) referralCodeCtrl.text = code;
+ }),
+ );
+
// Seed the form synchronously from whatever the parent cubit handed in.
// The non-country scalars are available immediately; the two country
// lookups still need the country service (the DTO only carries 2-letter
@@ -202,6 +212,14 @@ class _KycRegistrationViewState extends State {
body: BlocListener(
listener: (context, state) async {
if (state is KycRegistrationSubmitSuccess) {
+ // Persist a manually entered invite/promo code for post-auth bind
+ // (same stash path as a deeplink). Empty field leaves any prior
+ // deeplink stash untouched.
+ final entered = referralCodeCtrl.text.trim();
+ if (entered.isNotEmpty) {
+ unawaited(stashPendingReferralCode(entered));
+ }
+
// The submit cubit only emits Success after a successful EIP-712
// sign through `_signEip712`, regardless of the resulting backend
// status (completed, pendingReview, forwardingFailed,
@@ -309,6 +327,7 @@ class _KycRegistrationViewState extends State {
nationalityCtrl: nationalityCtrl,
phoneCtrl: phoneCtrl,
birthdayCtrl: birthdayCtrl,
+ referralCodeCtrl: referralCodeCtrl,
initialNationality: _initialNationality,
);
@@ -373,6 +392,7 @@ class _KycRegistrationViewState extends State {
lastnameCtrl.dispose();
phoneCtrl.dispose();
nationalityCtrl.dispose();
+ referralCodeCtrl.dispose();
addressStreetCtrl.dispose();
addressStreetNumberCtrl.dispose();
postalCodeCtrl.dispose();
diff --git a/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart b/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart
index 748b7da65..f8cb5a2af 100644
--- a/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart
+++ b/lib/screens/kyc/steps/registration/steps/kyc_registration_personal_step.dart
@@ -19,6 +19,7 @@ class KycRegistrationPersonalStep extends StatelessWidget {
final ValueNotifier birthdayCtrl;
final ValueNotifier phoneCtrl;
final ValueNotifier nationalityCtrl;
+ final TextEditingController? referralCodeCtrl;
final Country? initialNationality;
KycRegistrationPersonalStep({
@@ -29,6 +30,7 @@ class KycRegistrationPersonalStep extends StatelessWidget {
required this.phoneCtrl,
required this.nationalityCtrl,
required this.birthdayCtrl,
+ this.referralCodeCtrl,
this.initialNationality,
});
@@ -105,6 +107,13 @@ class KycRegistrationPersonalStep extends StatelessWidget {
initialValue: initialNationality,
onChanged: (country) => nationalityCtrl.value = country,
),
+ if (referralCodeCtrl != null)
+ LabeledTextField(
+ label: S.of(context).referralCodeOptional,
+ hintText: S.of(context).referralCodeHint,
+ controller: referralCodeCtrl!,
+ textCapitalization: TextCapitalization.characters,
+ ),
Padding(
padding: const .symmetric(vertical: 16.0),
child: AppFilledButton(
diff --git a/lib/screens/referral/cubit/referral_cubit.dart b/lib/screens/referral/cubit/referral_cubit.dart
new file mode 100644
index 000000000..f4d96aa60
--- /dev/null
+++ b/lib/screens/referral/cubit/referral_cubit.dart
@@ -0,0 +1,115 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_invite_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_summary_dto.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart';
+
+part 'referral_state.dart';
+
+class ReferralCubit extends Cubit {
+ final RealUnitReferralService _service;
+
+ ReferralCubit(this._service) : super(const ReferralInitial());
+
+ Future load() async {
+ emit(const ReferralLoading());
+ try {
+ final summary = await _service.getSummary();
+ if (!summary.eligible) {
+ emit(const ReferralNotEligible());
+ return;
+ }
+ if (!summary.termsAccepted) {
+ emit(ReferralNeedsTerms(summary: summary));
+ return;
+ }
+ final invites = await _service.getInvites();
+ emit(ReferralOverviewLoaded(summary: summary, invites: invites));
+ } on ApiException catch (e) {
+ emit(ReferralFailure(message: e.message));
+ } catch (e) {
+ emit(ReferralFailure(message: e.toString()));
+ }
+ }
+
+ Future acceptTerms() async {
+ final current = state;
+ if (current is! ReferralNeedsTerms && current is! ReferralTermsAccepting) {
+ return;
+ }
+ final summary = switch (current) {
+ ReferralNeedsTerms(:final summary) => summary,
+ ReferralTermsAccepting(:final summary) => summary,
+ _ => null,
+ };
+ if (summary == null) return;
+
+ emit(ReferralTermsAccepting(summary: summary));
+ try {
+ await _service.acceptTerms();
+ final refreshed = await _service.getSummary();
+ final invites = await _service.getInvites();
+ emit(ReferralOverviewLoaded(summary: refreshed, invites: invites));
+ } on ApiException catch (e) {
+ emit(ReferralNeedsTerms(summary: summary, errorMessage: e.message));
+ } catch (e) {
+ emit(ReferralNeedsTerms(summary: summary, errorMessage: e.toString()));
+ }
+ }
+
+ Future createInvite({required String guestName}) async {
+ final current = state;
+ if (current is! ReferralOverviewLoaded && current is! ReferralCreateReady) {
+ return;
+ }
+ final summary = switch (current) {
+ ReferralOverviewLoaded(:final summary) => summary,
+ ReferralCreateReady(:final summary) => summary,
+ ReferralCreating(:final summary) => summary,
+ _ => null,
+ };
+ if (summary == null) return;
+
+ emit(ReferralCreating(summary: summary, guestName: guestName));
+ try {
+ final created = await _service.createInvite(guestName: guestName);
+ emit(ReferralInviteCreated(summary: summary, invite: created));
+ } on ApiException catch (e) {
+ emit(ReferralCreateReady(summary: summary, errorMessage: e.message));
+ } catch (e) {
+ emit(ReferralCreateReady(summary: summary, errorMessage: e.toString()));
+ }
+ }
+
+ void openCreate() {
+ final current = state;
+ if (current is ReferralOverviewLoaded) {
+ emit(ReferralCreateReady(summary: current.summary));
+ } else if (current is ReferralInviteCreated) {
+ emit(ReferralCreateReady(summary: current.summary));
+ }
+ }
+
+ Future refreshOverview() async {
+ try {
+ final summary = await _service.getSummary();
+ final invites = await _service.getInvites();
+ emit(ReferralOverviewLoaded(summary: summary, invites: invites));
+ } on ApiException catch (e) {
+ emit(ReferralFailure(message: e.message));
+ } catch (e) {
+ emit(ReferralFailure(message: e.toString()));
+ }
+ }
+
+ Future bindCode(String code) async {
+ try {
+ return await _service.bind(code: code);
+ } on ApiException {
+ rethrow;
+ }
+ }
+}
diff --git a/lib/screens/referral/cubit/referral_eligibility_cubit.dart b/lib/screens/referral/cubit/referral_eligibility_cubit.dart
new file mode 100644
index 000000000..4225182c4
--- /dev/null
+++ b/lib/screens/referral/cubit/referral_eligibility_cubit.dart
@@ -0,0 +1,24 @@
+import 'package:equatable/equatable.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart';
+
+part 'referral_eligibility_state.dart';
+
+/// Loads `summary.eligible` for dashboard / settings entry gates.
+/// Failures hide the entry (same idea as sell: API decides eligibility).
+class ReferralEligibilityCubit extends Cubit {
+ final RealUnitReferralService _service;
+
+ ReferralEligibilityCubit(this._service)
+ : super(const ReferralEligibilityInitial());
+
+ Future load() async {
+ emit(const ReferralEligibilityLoading());
+ try {
+ final summary = await _service.getSummary();
+ emit(ReferralEligibilityLoaded(eligible: summary.eligible));
+ } catch (_) {
+ emit(const ReferralEligibilityLoaded(eligible: false));
+ }
+ }
+}
diff --git a/lib/screens/referral/cubit/referral_eligibility_state.dart b/lib/screens/referral/cubit/referral_eligibility_state.dart
new file mode 100644
index 000000000..a7e33d205
--- /dev/null
+++ b/lib/screens/referral/cubit/referral_eligibility_state.dart
@@ -0,0 +1,25 @@
+part of 'referral_eligibility_cubit.dart';
+
+abstract class ReferralEligibilityState extends Equatable {
+ const ReferralEligibilityState();
+
+ @override
+ List