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 get props => []; +} + +class ReferralEligibilityInitial extends ReferralEligibilityState { + const ReferralEligibilityInitial(); +} + +class ReferralEligibilityLoading extends ReferralEligibilityState { + const ReferralEligibilityLoading(); +} + +class ReferralEligibilityLoaded extends ReferralEligibilityState { + final bool eligible; + + const ReferralEligibilityLoaded({required this.eligible}); + + @override + List get props => [eligible]; +} diff --git a/lib/screens/referral/cubit/referral_state.dart b/lib/screens/referral/cubit/referral_state.dart new file mode 100644 index 000000000..a63349184 --- /dev/null +++ b/lib/screens/referral/cubit/referral_state.dart @@ -0,0 +1,98 @@ +part of 'referral_cubit.dart'; + +abstract class ReferralState extends Equatable { + const ReferralState(); + + @override + List get props => []; +} + +class ReferralInitial extends ReferralState { + const ReferralInitial(); +} + +class ReferralLoading extends ReferralState { + const ReferralLoading(); +} + +class ReferralNotEligible extends ReferralState { + const ReferralNotEligible(); +} + +class ReferralNeedsTerms extends ReferralState { + final ReferralSummaryDto summary; + final String? errorMessage; + + const ReferralNeedsTerms({required this.summary, this.errorMessage}); + + @override + List get props => [ + summary.eligible, + summary.termsAccepted, + errorMessage, + ]; +} + +class ReferralTermsAccepting extends ReferralState { + final ReferralSummaryDto summary; + + const ReferralTermsAccepting({required this.summary}); + + @override + List get props => [summary.eligible, summary.termsAccepted]; +} + +class ReferralOverviewLoaded extends ReferralState { + final ReferralSummaryDto summary; + final List invites; + + const ReferralOverviewLoaded({required this.summary, required this.invites}); + + @override + List get props => [ + summary.openCount, + summary.creditedCount, + summary.realuSum, + summary.chfSum, + invites.map((i) => i.id).toList(), + ]; +} + +class ReferralCreateReady extends ReferralState { + final ReferralSummaryDto summary; + final String? errorMessage; + + const ReferralCreateReady({required this.summary, this.errorMessage}); + + @override + List get props => [summary.openCount, errorMessage]; +} + +class ReferralCreating extends ReferralState { + final ReferralSummaryDto summary; + final String guestName; + + const ReferralCreating({required this.summary, required this.guestName}); + + @override + List get props => [summary.openCount, guestName]; +} + +class ReferralInviteCreated extends ReferralState { + final ReferralSummaryDto summary; + final ReferralCreatedInviteDto invite; + + const ReferralInviteCreated({required this.summary, required this.invite}); + + @override + List get props => [invite.code, invite.url, invite.guestName]; +} + +class ReferralFailure extends ReferralState { + final String message; + + const ReferralFailure({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/screens/referral/referral_create_page.dart b/lib/screens/referral/referral_create_page.dart new file mode 100644 index 000000000..36dd8e377 --- /dev/null +++ b/lib/screens/referral/referral_create_page.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; +import 'package:share_plus/share_plus.dart'; + +class ReferralCreatePage extends StatelessWidget { + const ReferralCreatePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + final cubit = ReferralCubit(getIt()); + // Seed create-ready after a summary load so guest-name submit can run. + cubit.load().then((_) { + if (!cubit.isClosed) cubit.openCreate(); + }); + return cubit; + }, + child: const ReferralCreateView(), + ); + } +} + +class ReferralCreateView extends StatefulWidget { + const ReferralCreateView({super.key}); + + @override + State createState() => _ReferralCreateViewState(); +} + +class _ReferralCreateViewState extends State { + final _nameCtrl = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _nameCtrl.dispose(); + super.dispose(); + } + + String _shareText({ + required String guestName, + required String url, + required String? copyText, + }) { + final s = S.of(context); + final hostName = 'RealUnit'; + return s.referralShareText(guestName, hostName, url).isNotEmpty + ? s.referralShareText(guestName, hostName, url) + : (copyText ?? url); + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Scaffold( + appBar: AppBar(title: Text(s.referralCreateInvite)), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state is ReferralLoading || state is ReferralInitial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is ReferralInviteCreated) { + final text = _shareText( + guestName: state.invite.guestName, + url: state.invite.url, + copyText: state.invite.copyText, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + s.referralInviteUrlLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + SelectableText( + state.invite.url, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: RealUnitColors.realUnitBlue, + ), + ), + ], + ), + actions: [ + AppFilledButton( + label: s.referralCopyInviteLink, + variant: FilledButtonVariant.secondary, + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: state.invite.url), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(s.copyClipboard)), + ); + } + }, + ), + AppFilledButton( + label: s.referralShareInviteLink, + onPressed: () => Share.share(text), + ), + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: AppFilledButton( + label: s.done, + variant: FilledButtonVariant.secondary, + onPressed: () => context.pop(true), + ), + ), + ], + ), + ); + } + + final creating = state is ReferralCreating; + final error = state is ReferralCreateReady + ? state.errorMessage + : null; + final canSubmit = + state is ReferralCreateReady || + state is ReferralOverviewLoaded || + state is ReferralCreating; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ScrollableActionsLayout( + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + s.referralCreateInviteDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + LabeledTextField( + label: s.referralGuestName, + hintText: s.name, + controller: _nameCtrl, + textCapitalization: TextCapitalization.words, + validator: (v) { + if (v == null || v.trim().isEmpty) return ''; + return null; + }, + ), + if (error != null && error.isNotEmpty) + Text( + error, + style: TextStyle(color: RealUnitColors.status.red600), + ), + ], + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + label: s.referralCreateInvite, + state: creating + ? FilledButtonState.loading + : FilledButtonState.idle, + onPressed: !canSubmit || creating + ? null + : () { + FocusManager.instance.primaryFocus?.unfocus(); + if (_formKey.currentState?.validate() ?? false) { + final cubit = context.read(); + if (cubit.state is ReferralOverviewLoaded) { + cubit.openCreate(); + } + cubit.createInvite( + guestName: _nameCtrl.text.trim(), + ); + } + }, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/referral/referral_overview_page.dart b/lib/screens/referral/referral_overview_page.dart new file mode 100644 index 000000000..dde688629 --- /dev/null +++ b/lib/screens/referral/referral_overview_page.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_invite_dto.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_cubit.dart'; +import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/outlined_tile.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class ReferralOverviewPage extends StatelessWidget { + const ReferralOverviewPage({super.key}); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Scaffold( + appBar: AppBar(title: Text(s.referrals)), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state is ReferralLoading || state is ReferralInitial) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ReferralFailure) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text(state.message, textAlign: TextAlign.center), + AppFilledButton( + label: s.retry, + onPressed: () => context.read().load(), + ), + ], + ), + ), + ); + } + if (state is! ReferralOverviewLoaded) { + return const SizedBox.shrink(); + } + + final summary = state.summary; + final openInvites = state.invites.where((i) => i.isOpen).toList(); + final creditedInvites = state.invites + .where((i) => i.isCredited) + .toList(); + final chfFormat = NumberFormat.currency( + locale: 'de_CH', + symbol: 'CHF', + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 20, + children: [ + Row( + spacing: 12, + children: [ + Expanded( + child: _CountTile( + label: s.referralStatusOpen, + count: summary.openCount, + ), + ), + Expanded( + child: _CountTile( + label: s.referralStatusCredited, + count: summary.creditedCount, + ), + ), + ], + ), + OutlinedTile( + leading: const Icon( + Icons.card_giftcard_outlined, + color: RealUnitColors.realUnitBlue, + size: 24, + ), + title: s.referralTotalReceived, + subtitle: + '${summary.realuSum.toStringAsFixed(0)} REALU\n' + '${chfFormat.format(summary.chfSum)}\n' + '${s.referralSharePrice}', + ), + if (openInvites.isNotEmpty) ...[ + Text( + s.referralStatusOpen, + style: Theme.of(context).textTheme.titleSmall, + ), + ...openInvites.map(_InviteRow.new), + ], + if (creditedInvites.isNotEmpty) ...[ + Text( + s.referralStatusCredited, + style: Theme.of(context).textTheme.titleSmall, + ), + ...creditedInvites.map(_InviteRow.new), + ], + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + label: s.referralCreateInvite, + onPressed: () async { + final created = await context.pushNamed( + SettingsRoutes.referralCreate, + ); + if (created == true && context.mounted) { + await context.read().refreshOverview(); + } + }, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _CountTile extends StatelessWidget { + final String label; + final int count; + + const _CountTile({required this.label, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: RealUnitColors.neutral200), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + '$count', + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + ); + } +} + +class _InviteRow extends StatelessWidget { + final ReferralInviteDto invite; + + const _InviteRow(this.invite); + + @override + Widget build(BuildContext context) { + return OutlinedTile( + leading: Icon( + invite.isCredited ? Icons.check_circle_outline : Icons.hourglass_empty, + color: RealUnitColors.realUnitBlue, + size: 24, + ), + title: invite.guestName, + subtitle: DateFormat('dd.MM.yyyy').format(invite.created.toLocal()), + ); + } +} diff --git a/lib/screens/referral/referral_page.dart b/lib/screens/referral/referral_page.dart new file mode 100644 index 000000000..a6d451fab --- /dev/null +++ b/lib/screens/referral/referral_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_cubit.dart'; +import 'package:realunit_wallet/screens/referral/referral_overview_page.dart'; +import 'package:realunit_wallet/screens/referral/referral_terms_page.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +/// Entry under `/settings/referral` — loads summary and shows terms or overview. +class ReferralPage extends StatelessWidget { + const ReferralPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ReferralCubit(getIt())..load(), + child: const ReferralGateView(), + ); + } +} + +class ReferralGateView extends StatelessWidget { + const ReferralGateView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is ReferralLoading || state is ReferralInitial) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).referrals)), + body: const Center(child: CircularProgressIndicator()), + ); + } + if (state is ReferralNotEligible) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).referrals)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + S.of(context).referralNotEligible, + textAlign: TextAlign.center, + ), + ), + ), + ); + } + if (state is ReferralNeedsTerms || state is ReferralTermsAccepting) { + return const ReferralTermsPage(); + } + if (state is ReferralOverviewLoaded || + state is ReferralCreateReady || + state is ReferralCreating || + state is ReferralInviteCreated) { + return const ReferralOverviewPage(); + } + if (state is ReferralFailure) { + return Scaffold( + appBar: AppBar(title: Text(S.of(context).referrals)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text(state.message, textAlign: TextAlign.center), + AppFilledButton( + label: S.of(context).retry, + onPressed: () => context.read().load(), + ), + ], + ), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/screens/referral/referral_terms_page.dart b/lib/screens/referral/referral_terms_page.dart new file mode 100644 index 000000000..050a70678 --- /dev/null +++ b/lib/screens/referral/referral_terms_page.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_cubit.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class ReferralTermsPage extends StatefulWidget { + /// Pre-loaded markdown for golden tests. + @visibleForTesting + final String? initialMarkdownContent; + + const ReferralTermsPage({super.key, this.initialMarkdownContent}); + + @override + State createState() => _ReferralTermsPageState(); +} + +class _ReferralTermsPageState extends State { + String? _markdown; + bool _loadFailed = false; + bool _accepted = false; + + @override + void initState() { + super.initState(); + if (widget.initialMarkdownContent != null) { + _markdown = widget.initialMarkdownContent; + } else { + _loadMarkdown(); + } + } + + Future _loadMarkdown() async { + final code = context.read().state.language.code; + final assetPath = 'assets/legal/referral_terms_$code.md'; + try { + final content = await rootBundle.loadString(assetPath, cache: false); + if (mounted) setState(() => _markdown = content); + } catch (_) { + if (mounted) setState(() => _loadFailed = true); + } + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Scaffold( + appBar: AppBar(title: Text(s.referrals)), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + final accepting = state is ReferralTermsAccepting; + final error = state is ReferralNeedsTerms + ? state.errorMessage + : null; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + if (_loadFailed) + Text(s.legalDocumentLoadFailed) + else if (_markdown == null) + const Center(child: CircularProgressIndicator()) + else + MarkdownBody(data: _markdown!), + if (error != null && error.isNotEmpty) + Text( + error, + style: TextStyle(color: RealUnitColors.status.red600), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: _accepted, + onChanged: accepting + ? null + : (v) => setState(() => _accepted = v ?? false), + title: Text(s.referralTermsCheckbox), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + label: s.referralCreateInvite, + state: accepting + ? FilledButtonState.loading + : FilledButtonState.idle, + onPressed: _accepted && !accepting + ? () => context.read().acceptTerms() + : null, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/referral/widgets/referral_entry_card.dart b/lib/screens/referral/widgets/referral_entry_card.dart new file mode 100644 index 000000000..2913644fe --- /dev/null +++ b/lib/screens/referral/widgets/referral_entry_card.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_eligibility_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/outlined_tile.dart'; + +/// Full-width dashboard card gated by `summary.eligible` from the API. +class ReferralEntryCard extends StatelessWidget { + const ReferralEntryCard({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + ReferralEligibilityCubit(getIt())..load(), + child: const _ReferralEntryCardView(), + ); + } +} + +class _ReferralEntryCardView extends StatelessWidget { + const _ReferralEntryCardView(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final eligible = state is ReferralEligibilityLoaded && state.eligible; + if (!eligible) return const SizedBox.shrink(); + + final s = S.of(context); + return OutlinedTile( + leading: const Icon( + Icons.people_outline, + color: RealUnitColors.realUnitBlue, + size: 24, + ), + title: s.referrals, + subtitle: s.referralsSubtitle, + trailingIcon: Icons.chevron_right_rounded, + onTap: () => context.pushNamed(SettingsRoutes.referral), + ); + }, + ); + } +} diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index d036b7587..149461723 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -4,10 +4,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/generated/release_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; +import 'package:realunit_wallet/screens/referral/cubit/referral_eligibility_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_section.dart'; @@ -28,134 +30,174 @@ class SettingsPage extends StatelessWidget { ); @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text( - S.of(context).settings, - ), - ), - body: SingleChildScrollView( - child: Column( - children: [ - BlocBuilder( - bloc: getIt(), - builder: (context, state) => SettingsSections( - settings: [ - SettingOption( - title: S.of(context).settingsLanguages, - leading: const LanguagesIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.language.name, - onTap: () => context.pushNamed(SettingsRoutes.languages), - ), - SettingOption( - title: S.of(context).settingsCurrency, - leading: const CurrencyIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.currency.code, - onTap: () => context.pushNamed(SettingsRoutes.currencies), - ), - if (kDebugMode) - SettingOption( - title: S.of(context).settingsNetwork, - leading: const NodesIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.networkMode.localizedName(context), - onTap: () => context.pushNamed(SettingsRoutes.network), - ), - SettingOption( - title: S.of(context).settingsTaxReport, - leading: const DocumentReportIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.taxReport), - ), - SettingOption( - title: S.of(context).userData, - leading: const UserCircleIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.userData), - ), - SettingOption( - title: S.of(context).legalDocuments, - leading: const Icon( - Icons.description_rounded, - size: 24, - color: RealUnitColors.realUnitBlue, - ), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.legalDocuments), - ), - SettingOption( - title: S.of(context).contact, - leading: const Icon( - Icons.info_rounded, - color: RealUnitColors.realUnitBlue, - ), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.contact), - ), - SettingOption( - title: S.of(context).settingsSecurity, - leading: const Icon( - Icons.shield_outlined, - size: 24, - color: RealUnitColors.realUnitBlue, - ), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.security), - ), - SettingOption( - title: S.of(context).walletAddress, - leading: const RealUnitIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.pushNamed(SettingsRoutes.walletAddress), - ), - if (context.read().state.openWallet?.walletType == WalletType.software) - SettingOption( - title: S.of(context).settingsWalletBackup, - leading: const KeySolidIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.pushNamed( - PinRoutes.gate, - extra: VerifyPinParams( - onAuthenticated: () => context.pushReplacementNamed(SettingsRoutes.seed), - description: S.of(context).pinVerifySeedDescription, - ), + Widget build(BuildContext context) => BlocProvider( + create: (_) => + ReferralEligibilityCubit(getIt())..load(), + child: Scaffold( + appBar: AppBar(title: Text(S.of(context).settings)), + body: SingleChildScrollView( + child: Column( + children: [ + BlocBuilder( + bloc: getIt(), + builder: (context, state) => + BlocBuilder< + ReferralEligibilityCubit, + ReferralEligibilityState + >( + builder: (context, eligibility) => SettingsSections( + settings: [ + SettingOption( + title: S.of(context).settingsLanguages, + leading: const LanguagesIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.language.name, + onTap: () => + context.pushNamed(SettingsRoutes.languages), + ), + SettingOption( + title: S.of(context).settingsCurrency, + leading: const CurrencyIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.currency.code, + onTap: () => + context.pushNamed(SettingsRoutes.currencies), + ), + if (kDebugMode) + SettingOption( + title: S.of(context).settingsNetwork, + leading: const NodesIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.networkMode.localizedName( + context, + ), + onTap: () => + context.pushNamed(SettingsRoutes.network), + ), + SettingOption( + title: S.of(context).settingsTaxReport, + leading: const DocumentReportIcon(size: 24), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.taxReport), + ), + if (eligibility is ReferralEligibilityLoaded && + eligibility.eligible) + SettingOption( + title: S.of(context).referrals, + leading: const Icon( + Icons.people_outline, + size: 24, + color: RealUnitColors.realUnitBlue, + ), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.referral), + ), + SettingOption( + title: S.of(context).userData, + leading: const UserCircleIcon(size: 24), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.userData), + ), + SettingOption( + title: S.of(context).legalDocuments, + leading: const Icon( + Icons.description_rounded, + size: 24, + color: RealUnitColors.realUnitBlue, + ), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.legalDocuments), + ), + SettingOption( + title: S.of(context).contact, + leading: const Icon( + Icons.info_rounded, + color: RealUnitColors.realUnitBlue, + ), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.contact), + ), + SettingOption( + title: S.of(context).settingsSecurity, + leading: const Icon( + Icons.shield_outlined, + size: 24, + color: RealUnitColors.realUnitBlue, + ), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.security), + ), + SettingOption( + title: S.of(context).walletAddress, + leading: const RealUnitIcon(size: 24), + trailing: _forwardIcon, + onTap: () => + context.pushNamed(SettingsRoutes.walletAddress), + ), + if (context + .read() + .state + .openWallet + ?.walletType == + WalletType.software) + SettingOption( + title: S.of(context).settingsWalletBackup, + leading: const KeySolidIcon(size: 24), + trailing: _forwardIcon, + onTap: () => context.pushNamed( + PinRoutes.gate, + extra: VerifyPinParams( + onAuthenticated: () => context + .pushReplacementNamed(SettingsRoutes.seed), + description: S + .of(context) + .pinVerifySeedDescription, + ), + ), + ), + ], ), ), - ], ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Divider(color: RealUnitColors.neutral200), - ), - SettingsSections( - settings: [ - SettingOption( - title: S.of(context).settingsDeleteWallet, - leading: const XCircleIcon(size: 24), - onTap: () async { - bool? isLogout = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => const SettingsConfirmLogoutWalletSheet(), - ); - if (isLogout ?? false) { - await Future.delayed(const Duration(milliseconds: 300)); - if (context.mounted) { - await context.read().reset(); + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Divider(color: RealUnitColors.neutral200), + ), + SettingsSections( + settings: [ + SettingOption( + title: S.of(context).settingsDeleteWallet, + leading: const XCircleIcon(size: 24), + onTap: () async { + bool? isLogout = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const SettingsConfirmLogoutWalletSheet(), + ); + if (isLogout ?? false) { + await Future.delayed(const Duration(milliseconds: 300)); if (context.mounted) { - context.read().add(const DeleteCurrentWalletEvent()); + await context.read().reset(); + if (context.mounted) { + context.read().add( + const DeleteCurrentWalletEvent(), + ); + } } } - } - }, - ), - ], - ), - const SettingsVersionUnlock(releaseTag: releaseTag), - ], + }, + ), + ], + ), + const SettingsVersionUnlock(releaseTag: releaseTag), + ], + ), ), ), ); diff --git a/lib/screens/transaction_history/widgets/transaction_history_row.dart b/lib/screens/transaction_history/widgets/transaction_history_row.dart index da3382ea6..b97fb17a3 100644 --- a/lib/screens/transaction_history/widgets/transaction_history_row.dart +++ b/lib/screens/transaction_history/widgets/transaction_history_row.dart @@ -101,7 +101,11 @@ class TransactionHistoryRowView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isOutbound ? S.of(context).transactionSell : S.of(context).transactionBuy, + transaction.type == TransactionTypes.referralPayout + ? S.of(context).referralPayout + : isOutbound + ? S.of(context).transactionSell + : S.of(context).transactionBuy, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -116,11 +120,25 @@ class TransactionHistoryRowView extends StatelessWidget { color: RealUnitColors.neutral500, ), ), + if (transaction.type == TransactionTypes.referralPayout && + transaction.data != null && + transaction.data!.isNotEmpty) + Text( + S.of(context).referralPayoutChf(transaction.data!), + style: const TextStyle( + fontSize: 12, + height: 16 / 12, + color: RealUnitColors.neutral500, + ), + ), ], ), ), HideAmountText( - leadingSymbol: isOutbound ? '-' : '+', + leadingSymbol: isOutbound && + transaction.type != TransactionTypes.referralPayout + ? '-' + : '+', amount: transaction.amount, decimals: transaction.asset.decimals, fractionalDigits: 0, @@ -132,32 +150,33 @@ class TransactionHistoryRowView extends StatelessWidget { height: 20 / 16, ), ), - state is TransactionHistoryReceiptLoading - ? const Padding( - padding: EdgeInsets.all(4.0), - child: SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, + if (transaction.type != TransactionTypes.referralPayout) + state is TransactionHistoryReceiptLoading + ? const Padding( + padding: EdgeInsets.all(4.0), + child: SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: RealUnitColors.realUnitBlue, + ), + ), + ) + : GestureDetector( + onTap: () { + context.read().generateReceipt( + transaction.txId, + currency: context.read().state.currency, + language: context.read().state.language, + ); + }, + child: const Icon( + size: 20, + Icons.file_download_outlined, color: RealUnitColors.realUnitBlue, ), ), - ) - : GestureDetector( - onTap: () { - context.read().generateReceipt( - transaction.txId, - currency: context.read().state.currency, - language: context.read().state.language, - ); - }, - child: const Icon( - size: 20, - Icons.file_download_outlined, - color: RealUnitColors.realUnitBlue, - ), - ), ], ), ], diff --git a/lib/setup/di.dart b/lib/setup/di.dart index 77f3f36b2..85a2e4d75 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -32,6 +32,7 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_account_service.d import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pay_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; @@ -202,6 +203,9 @@ void setupServices() { getIt.registerFactory( () => RealUnitLegalService(getIt(), getIt()), ); + getIt.registerFactory( + () => RealUnitReferralService(getIt(), getIt()), + ); getIt.registerFactory( () => RealUnitPayService(getIt(), getIt()), ); diff --git a/lib/setup/routing/boot_navigation.dart b/lib/setup/routing/boot_navigation.dart index b841f9e46..bf0a3890e 100644 --- a/lib/setup/routing/boot_navigation.dart +++ b/lib/setup/routing/boot_navigation.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/setup/routing/referral_bind.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; @@ -222,6 +223,7 @@ void applyBootNavAction( if (payload != null) { router.goNamed(routeName); unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); + unawaited(bindPendingReferralCode(router)); return; } } @@ -231,6 +233,9 @@ void applyBootNavAction( // locked-warm-resume payment deeplinks can still replay. Do NOT clear // the pending-payment stash here. router.goNamed(routeName); + if (routeName == AppRoutes.dashboard) { + unawaited(bindPendingReferralCode(router)); + } return; case BootNavRestore(:final location): // Return to the in-flight route the user was on before the re-lock, @@ -254,6 +259,7 @@ void applyBootNavAction( if (payload != null) { unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); } + unawaited(bindPendingReferralCode(router)); return; case BootNavStay(): // Already on a valid non-gate route — discard any stale resume capture. @@ -266,6 +272,7 @@ void applyBootNavAction( if (payload != null) { unawaited(router.pushNamed(AppRoutes.pay, extra: payload)); } + unawaited(bindPendingReferralCode(router)); return; } } diff --git a/lib/setup/routing/referral_bind.dart b/lib/setup/routing/referral_bind.dart new file mode 100644 index 000000000..304efe110 --- /dev/null +++ b/lib/setup/routing/referral_bind.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_referral_service.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/referral_pending_code.dart'; +import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; + +/// Binds a stashed or freshly delivered referral code once the session is unlocked. +/// Shows the API campaign text for promo binds; navigates to referral overview for invites. +Future bindPendingReferralCode(GoRouter router, {String? code}) async { + final resolved = code ?? await takePendingReferralCode(); + if (resolved == null || resolved.isEmpty) return; + + try { + final result = await getIt().bind(code: resolved); + final ctx = router.routerDelegate.navigatorKey.currentContext; + if (ctx == null || !ctx.mounted) return; + + if (result.isPromo) { + final lang = getIt().state.language.code; + final text = result.campaignTextForLocale(lang); + if (text != null && text.isNotEmpty) { + await showDialog( + context: ctx, + builder: (dialogContext) => AlertDialog( + title: Text(S.of(dialogContext).referralPromoTitle), + content: SingleChildScrollView(child: Text(text)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(S.of(dialogContext).close), + ), + ], + ), + ); + } + } else { + unawaited(router.pushNamed(SettingsRoutes.referral)); + } + } on ApiException { + // Deeplink bind failures must not crash boot; the user can retry via settings. + } catch (_) { + // Transport failures stay silent on the deeplink path. + } +} + +/// Deferred bind used from redirects — re-checks PIN unlock at execution time. +void scheduleReferralBind(GoRouter router, String code) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final pinState = getIt().state; + if (!(pinState.isPinVerified && pinState.isPinSetup)) { + unawaited(stashPendingReferralCode(code)); + return; + } + unawaited(bindPendingReferralCode(router, code: code)); + }); +} diff --git a/lib/setup/routing/referral_pending_code.dart b/lib/setup/routing/referral_pending_code.dart new file mode 100644 index 000000000..6515a9eb5 --- /dev/null +++ b/lib/setup/routing/referral_pending_code.dart @@ -0,0 +1,59 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// SharedPreferences key for a referral/promo invite code delivered via +/// deeplink before the user is authenticated. +const String pendingReferralCodeKey = 'pending_referral_code'; + +/// In-process stash mirroring [stashPendingPaymentDeeplink]: last-write-wins, +/// no queue. Survives warm-locked PIN screens within the same process. +String? _pendingReferralCode; + +/// Persist [code] for post-unlock / post-KYC bind. +/// +/// Play Store install referrer is handled on the website (`&referrer=`); the +/// app only needs the code when a custom-scheme or https App Link delivers it +/// after install. If the OS drops the first open, the user re-taps the invite +/// link and this stash is filled again — that re-tap path is intentional. +Future stashPendingReferralCode(String code) async { + final trimmed = code.trim(); + if (trimmed.isEmpty) return; + final capped = trimmed.length > 256 ? trimmed.substring(0, 256) : trimmed; + _pendingReferralCode = capped; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(pendingReferralCodeKey, capped); +} + +/// Returns and clears the pending code (memory + SharedPreferences). +Future takePendingReferralCode() async { + final inMemory = _pendingReferralCode; + _pendingReferralCode = null; + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(pendingReferralCodeKey); + await prefs.remove(pendingReferralCodeKey); + final code = inMemory ?? stored; + if (code == null || code.isEmpty) return null; + return code; +} + +/// Clears any stashed code without returning it. +Future clearPendingReferralCode() async { + _pendingReferralCode = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(pendingReferralCodeKey); +} + +/// Read-only peek (memory first, then SharedPreferences). Does not clear. +Future peekPendingReferralCode() async { + if (_pendingReferralCode != null) return _pendingReferralCode; + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(pendingReferralCodeKey); +} + +/// Synchronous in-memory peek for redirect tests / warm paths that already +/// stashed in this process. Does not read SharedPreferences. +String? peekPendingReferralCodeSync() => _pendingReferralCode; + +/// Test-only: seed the in-memory stash without touching SharedPreferences. +void debugSetPendingReferralCodeSync(String? code) { + _pendingReferralCode = code; +} diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 72c9d050f..21b24ce0c 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -18,6 +18,8 @@ import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/receive/receive_page.dart'; +import 'package:realunit_wallet/screens/referral/referral_create_page.dart'; +import 'package:realunit_wallet/screens/referral/referral_page.dart'; import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart'; import 'package:realunit_wallet/screens/sell/sell_page.dart'; import 'package:realunit_wallet/screens/sell_bitbox/sell_bitbox_page.dart'; @@ -330,6 +332,18 @@ final GoRouter routerConfig = GoRouter( ), ], ), + GoRoute( + name: SettingsRoutes.referral, + path: 'referral', + builder: (_, _) => const ReferralPage(), + routes: [ + GoRoute( + name: SettingsRoutes.referralCreate, + path: 'create', + builder: (_, _) => const ReferralCreatePage(), + ), + ], + ), ], ), diff --git a/lib/setup/routing/routes/app_link_entry.dart b/lib/setup/routing/routes/app_link_entry.dart index 277f19859..f18c479ba 100644 --- a/lib/setup/routing/routes/app_link_entry.dart +++ b/lib/setup/routing/routes/app_link_entry.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; +import 'package:realunit_wallet/setup/routing/referral_bind.dart'; +import 'package:realunit_wallet/setup/routing/referral_pending_code.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; /// Custom URL scheme the app is opened with. Registered in @@ -53,11 +55,59 @@ String? extractPaymentDeeplinkPayload(String rawUri) { final isLightning = remainder.startsWith('lightning:'); final isBareLnurl = remainder.toUpperCase().startsWith('LNURL'); - final isHttpLnurlp = remainder.startsWith('http://') || remainder.startsWith('https://'); + final isHttpLnurlp = + remainder.startsWith('http://') || remainder.startsWith('https://'); if (!isLightning && !isBareLnurl && !isHttpLnurlp) return null; return remainder; } +/// Extracts a referral/promo invite code from a custom-scheme or https App Link. +/// +/// Matches: +/// - `realunit-wallet://invite/{code}` +/// - `realunit-wallet:invite/{code}` +/// - `https://realunit.app/invite/{code}` (Android App Links / iOS Universal Links) +/// +/// Returns the last path segment (trimmed, max 256) or null when not an invite link. +String? extractReferralInviteCode(Uri uri) { + if ((uri.scheme == 'https' || uri.scheme == 'http') && + uri.host == 'realunit.app') { + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.length >= 2 && segments.first == 'invite') { + return _capReferralCode(segments[1]); + } + return null; + } + + if (uri.scheme != appLinkScheme) return null; + + // Prefer structured Uri parts when hierarchical (`://invite/{code}`). + if (uri.host == 'invite') { + final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segments.isNotEmpty) return _capReferralCode(segments.first); + } + + // Fall back to raw-string parsing for opaque `realunit-wallet:invite/{code}` + // and any path-form the Uri parser did not split into host/path. + final raw = uri.toString(); + const prefix = '$appLinkScheme:'; + if (!raw.startsWith(prefix)) return null; + var remainder = raw.substring(prefix.length); + if (remainder.startsWith('//')) remainder = remainder.substring(2); + final withoutQuery = remainder.split('?').first; + final segments = withoutQuery.split('/').where((s) => s.isNotEmpty).toList(); + if (segments.length >= 2 && segments.first == 'invite') { + return _capReferralCode(segments[1]); + } + return null; +} + +String? _capReferralCode(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return null; + return trimmed.length > 256 ? trimmed.substring(0, 256) : trimmed; +} + /// Top-level go_router redirect for custom-scheme opens. /// /// A `realunit-wallet://…` open (canonical `realunit-wallet://open`) only brings @@ -140,15 +190,46 @@ String? extractPaymentDeeplinkPayload(String rawUri) { // only be exercised on a real device, and no integration_test/ harness exists // in this repo yet. The redirect is covered by widget tests in // app_link_entry_test.dart. -String? appLinkSchemeRedirect(GoRouterState state, String currentLocation, GoRouter router) { +String? appLinkSchemeRedirect( + GoRouterState state, + String currentLocation, + GoRouter router, +) { + // https App Links for /invite/{code} (Android) — go_router may receive them + // as hierarchical https URIs rather than the custom scheme. + if (state.uri.scheme == 'https' || state.uri.scheme == 'http') { + final httpsCode = extractReferralInviteCode(state.uri); + if (httpsCode != null) { + final current = Uri.parse(currentLocation); + final isInAppRoute = + current.scheme.isEmpty && current.path.startsWith('/'); + return _handleReferralInviteRedirect( + code: httpsCode, + isInAppRoute: isInAppRoute, + router: router, + ); + } + return null; + } + if (state.uri.scheme != appLinkScheme) return null; final current = Uri.parse(currentLocation); final isInAppRoute = current.scheme.isEmpty && current.path.startsWith('/'); + final inviteCode = extractReferralInviteCode(state.uri); + if (inviteCode != null) { + return _handleReferralInviteRedirect( + code: inviteCode, + isInAppRoute: isInAppRoute, + router: router, + ); + } + final paymentPayload = extractPaymentDeeplinkPayload(state.uri.toString()); if (paymentPayload != null) { if (isInAppRoute) { - if (getIt().state.isPinVerified && getIt().state.isPinSetup) { + if (getIt().state.isPinVerified && + getIt().state.isPinSetup) { // Warm resume, unlocked (including a step-up gate like /pinGate reached // from an already-unlocked seed-view / change-PIN flow): defer the push // to addPostFrameCallback (calling into the router synchronously from @@ -198,6 +279,27 @@ String? appLinkSchemeRedirect(GoRouterState state, String currentLocation, GoRou return hasPath ? appLinkUrl : null; } +/// Shared cold/warm handling for invite codes — same security properties as +/// the payment carve-out (never `go` over KYC `extra` / back stack; defer bind). +String? _handleReferralInviteRedirect({ + required String code, + required bool isInAppRoute, + required GoRouter router, +}) { + if (isInAppRoute) { + if (getIt().state.isPinVerified && + getIt().state.isPinSetup) { + unawaited(stashPendingReferralCode(code)); + scheduleReferralBind(router, code); + return null; + } + unawaited(stashPendingReferralCode(code)); + return null; + } + unawaited(stashPendingReferralCode(code)); + return appLinkColdStartLocation; +} + /// go_router exception handler paired with [appLinkSchemeRedirect]. /// /// With an `onException` handler installed, go_router keeps the current route @@ -213,7 +315,11 @@ String? appLinkSchemeRedirect(GoRouterState state, String currentLocation, GoRou // only be exercised on a real device, and no integration_test/ harness exists // in this repo yet. The handler is covered by widget tests in // app_link_entry_test.dart. -void appLinkOnException(BuildContext context, GoRouterState state, GoRouter router) { +void appLinkOnException( + BuildContext context, + GoRouterState state, + GoRouter router, +) { if (state.uri.scheme == appLinkScheme) return; // A non-scheme URL that matches no route is a programming error. Installing // onException removes go_router's default error screen, so fail loud where diff --git a/lib/setup/routing/routes/settings_routes.dart b/lib/setup/routing/routes/settings_routes.dart index 622a0874c..bd1e078cf 100644 --- a/lib/setup/routing/routes/settings_routes.dart +++ b/lib/setup/routing/routes/settings_routes.dart @@ -16,4 +16,6 @@ abstract final class SettingsRoutes { static const editName = 'settingsEditName'; static const editAddress = 'settingsEditAddress'; static const editPhone = 'settingsEditPhone'; + static const referral = 'settingsReferral'; + static const referralCreate = 'settingsReferralCreate'; } diff --git a/pubspec.lock b/pubspec.lock index a68f0b590..cc5896b9b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1319,6 +1319,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.25.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 + url: "https://pub.dev" + source: hosted + version: "11.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 775cd3438..2a7b0b3ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,7 @@ dependencies: qr_flutter: ^4.1.0 sentry_flutter: ^9.25.0 shared_preferences: ^2.5.2 + share_plus: ^11.0.0 sqlite3: ^3.3.0 url_launcher: ^6.3.1 web3dart: ^2.7.1 diff --git a/test/packages/service/referral_payout_merger_test.dart b/test/packages/service/referral_payout_merger_test.dart new file mode 100644 index 000000000..280c3410e --- /dev/null +++ b/test/packages/service/referral_payout_merger_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/referral/dto/referral_payout_dto.dart'; +import 'package:realunit_wallet/packages/service/referral_payout_merger.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; + +const _wallet = '0x0000000000000000000000000000000000000001'; + +Transaction _onChain(String txId) => Transaction( + height: 1, + txId: txId, + chainId: realUnitAsset.chainId, + senderAddress: '0xprize', + receiverAddress: _wallet, + amount: BigInt.from(20), + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: '', + data: null, + timestamp: DateTime.utc(2026, 8, 1), +); + +void main() { + group('mergeReferralPayouts', () { + test( + 'keeps frozen chfValue on the payout row and drops the matching on-chain transfer', + () { + final payout = ReferralPayoutDto( + id: 7, + amount: 20, + chfValue: 246.5, + created: DateTime.utc(2026, 8, 24, 10), + kind: 'Invite', + status: 'Complete', + txHash: '0xabc', + ); + + final merged = mergeReferralPayouts( + onChain: [_onChain('0xabc'), _onChain('0xother')], + payouts: [payout], + asset: realUnitAsset, + walletAddress: _wallet, + ); + + expect(merged.map((t) => t.txId), ['0xabc', '0xother']); + final prize = merged.firstWhere((t) => t.txId == '0xabc'); + expect(prize.type, TransactionTypes.referralPayout); + expect(prize.amount, BigInt.from(20)); + expect(prize.data, '246.5'); + expect(prize.timestamp, DateTime.utc(2026, 8, 24, 10)); + }, + ); + + test('uses a synthetic txId when the payout has no hash yet', () { + final payout = ReferralPayoutDto( + id: 3, + amount: 20, + chfValue: 10, + created: DateTime.utc(2026, 8, 24), + kind: 'Promo', + status: 'Pending', + ); + + final merged = mergeReferralPayouts( + onChain: const [], + payouts: [payout], + asset: realUnitAsset, + walletAddress: _wallet, + ); + + expect(merged.single.txId, 'referral-payout-3'); + expect(merged.single.type, TransactionTypes.referralPayout); + }); + }); +} diff --git a/test/setup/routing/app_link_entry_test.dart b/test/setup/routing/app_link_entry_test.dart index 608cfafe0..b7d5ac86e 100644 --- a/test/setup/routing/app_link_entry_test.dart +++ b/test/setup/routing/app_link_entry_test.dart @@ -18,9 +18,9 @@ void main() { setUp(() { pinAuthCubit = _MockPinAuthCubit(); GetIt.instance.registerSingleton(pinAuthCubit); - when(() => pinAuthCubit.state).thenReturn( - const PinAuthState(isPinVerified: true, isPinSetup: true), - ); + when( + () => pinAuthCubit.state, + ).thenReturn(const PinAuthState(isPinVerified: true, isPinSetup: true)); }); tearDown(() => GetIt.instance.reset()); @@ -43,22 +43,26 @@ void main() { routes: [ GoRoute( path: '/home', - builder: (_, _) => const Scaffold(body: Text('HOME', key: Key('home'))), + builder: (_, _) => + const Scaffold(body: Text('HOME', key: Key('home'))), ), GoRoute( path: '/dashboard', - builder: (_, _) => const Scaffold(body: Text('DASH', key: Key('dashboard'))), + builder: (_, _) => + const Scaffold(body: Text('DASH', key: Key('dashboard'))), ), GoRoute( path: '/settings', - builder: (_, _) => const Scaffold(body: Text('SET', key: Key('settings'))), + builder: (_, _) => + const Scaffold(body: Text('SET', key: Key('settings'))), ), GoRoute( path: '/details', // Mirrors the real extra-required builders (/buyPaymentDetails, // /webView, …): rebuilding this route from a bare path throws. - builder: (_, state) => - Scaffold(body: Text(state.extra! as String, key: const Key('details'))), + builder: (_, state) => Scaffold( + body: Text(state.extra! as String, key: const Key('details')), + ), ), // Gate routes from `gateLocations` — needed so warm-resume tests can // land on a real lock path and prove /pay is NOT pushed over it. @@ -80,14 +84,16 @@ void main() { GoRoute( name: AppRoutes.pay, path: '/pay', - builder: (_, state) => Scaffold(body: Text('PAY:${state.extra}', key: const Key('pay'))), + builder: (_, state) => + Scaffold(body: Text('PAY:${state.extra}', key: const Key('pay'))), ), ], ); return router; } - String currentPath(GoRouter router) => router.routerDelegate.currentConfiguration.uri.path; + String currentPath(GoRouter router) => + router.routerDelegate.currentConfiguration.uri.path; Future pump(WidgetTester tester, {String initial = '/home'}) async { final router = buildRouter(initialLocation: initial); @@ -123,10 +129,7 @@ void main() { test('https lnurlp form extracts the URL verbatim', () { const url = 'https://api.dfx.swiss/v1/lnurlp/pl_abc123'; - expect( - extractPaymentDeeplinkPayload('realunit-wallet:$url'), - url, - ); + expect(extractPaymentDeeplinkPayload('realunit-wallet:$url'), url); }); test('canonical path-less open returns null', () { @@ -177,41 +180,38 @@ void main() { expect(find.byKey(const Key('settings')), findsOneWidget); }); - testWidgets( - 'warm resume: a scheme open keeps the user on a PUSHED route ' - '(how /kyc is reached from Buy/Sell)', - (tester) async { - final router = await pump(tester); - router.go('/dashboard'); - await tester.pumpAndSettle(); - - // The KYC flow is entered imperatively — context.pushNamed(AppRoutes.kyc) - // from the Buy/Sell buttons — so the flow route sits ON TOP of the base - // route instead of replacing it. /settings stands in for the pushed flow. - unawaited(router.push('/settings')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('settings')), findsOneWidget); + testWidgets('warm resume: a scheme open keeps the user on a PUSHED route ' + '(how /kyc is reached from Buy/Sell)', (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); - // Same promise as for go-routes above: the scheme open must NOT force - // any navigation. The pushed route (and with it any page-scoped state, - // e.g. the KycCubit) must survive the open. - router.go(appLinkUrl); - await tester.pumpAndSettle(); + // The KYC flow is entered imperatively — context.pushNamed(AppRoutes.kyc) + // from the Buy/Sell buttons — so the flow route sits ON TOP of the base + // route instead of replacing it. /settings stands in for the pushed flow. + unawaited(router.push('/settings')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('settings')), findsOneWidget); - expect(find.byKey(const Key('settings')), findsOneWidget); - expect(find.byKey(const Key('dashboard')), findsNothing); + // Same promise as for go-routes above: the scheme open must NOT force + // any navigation. The pushed route (and with it any page-scoped state, + // e.g. the KycCubit) must survive the open. + router.go(appLinkUrl); + await tester.pumpAndSettle(); - // A true no-op also preserves the imperative stack itself — the pushed - // route must still pop back to the base route it was pushed from. This - // guards against "fixes" that rebuild the page as a new base route - // (which would drop page-scoped state, `state.extra`, and the back - // stack while leaving the same page visible). - expect(router.canPop(), isTrue); - router.pop(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('dashboard')), findsOneWidget); - }, - ); + expect(find.byKey(const Key('settings')), findsOneWidget); + expect(find.byKey(const Key('dashboard')), findsNothing); + + // A true no-op also preserves the imperative stack itself — the pushed + // route must still pop back to the base route it was pushed from. This + // guards against "fixes" that rebuild the page as a new base route + // (which would drop page-scoped state, `state.extra`, and the back + // stack while leaving the same page visible). + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }); testWidgets( 'warm resume: a payment deeplink pushes /pay with the payload as extra ' @@ -268,7 +268,9 @@ void main() { // deleted, since the deferred callback would then unconditionally push). // Simulates a re-lock (AppLifecycleState.resumed -> onAppResumed()) // landing between decision and deferred frame. - when(() => pinAuthCubit.state).thenReturn(const PinAuthState(isPinVerified: false)); + when( + () => pinAuthCubit.state, + ).thenReturn(const PinAuthState(isPinVerified: false)); await tester.pumpAndSettle(); expect(find.byKey(const Key('pay')), findsNothing); @@ -277,20 +279,19 @@ void main() { }, ); - testWidgets( - 'warm resume: the canonical no-payload open never pushes /pay', - (tester) async { - final router = await pump(tester); - router.go('/dashboard'); - await tester.pumpAndSettle(); + testWidgets('warm resume: the canonical no-payload open never pushes /pay', ( + tester, + ) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); - router.go(appLinkUrl); - await tester.pumpAndSettle(); + router.go(appLinkUrl); + await tester.pumpAndSettle(); - expect(find.byKey(const Key('pay')), findsNothing); - expect(find.byKey(const Key('dashboard')), findsOneWidget); - }, - ); + expect(find.byKey(const Key('pay')), findsNothing); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }); // Genuinely-locked warm-resume: a payment deeplink while isPinVerified is // false (real PIN/unlock gates) must stash and stay — never push /pay over @@ -300,7 +301,9 @@ void main() { 'warm resume on /verifyPin while locked: payment deeplink stashes and does not push /pay', (tester) async { addTearDown(clearPendingPaymentDeeplink); - when(() => pinAuthCubit.state).thenReturn(const PinAuthState(isPinVerified: false)); + when( + () => pinAuthCubit.state, + ).thenReturn(const PinAuthState(isPinVerified: false)); const sampleLnurl = 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; final router = await pump(tester); @@ -328,9 +331,9 @@ void main() { 'payment deeplink stashes and does not push /pay', (tester) async { addTearDown(clearPendingPaymentDeeplink); - when(() => pinAuthCubit.state).thenReturn( - const PinAuthState(isPinSetup: false, isPinVerified: true), - ); + when( + () => pinAuthCubit.state, + ).thenReturn(const PinAuthState(isPinSetup: false, isPinVerified: true)); const sampleLnurl = 'LNURL1DP68GURN8GHJ7VF3XGENJVE5UMD9E3K7MF0V9CXJTMKXP6XCEF'; final router = await pump(tester); @@ -409,7 +412,11 @@ void main() { 'realunit-wallet://anything', ]) { final router = await pump(tester, initial: url); - expect(currentPath(router), appLinkColdStartLocation, reason: 'for $url'); + expect( + currentPath(router), + appLinkColdStartLocation, + reason: 'for $url', + ); expect( find.byKey(const Key('home')), findsOneWidget, @@ -419,24 +426,21 @@ void main() { }, ); - testWidgets( - 'warm resume: a crafted scheme URL to an auth path is a no-op ' - '(no navigation, PIN gate not bypassed)', - (tester) async { - final router = await pump(tester); - router.go('/settings'); - await tester.pumpAndSettle(); + testWidgets('warm resume: a crafted scheme URL to an auth path is a no-op ' + '(no navigation, PIN gate not bypassed)', (tester) async { + final router = await pump(tester); + router.go('/settings'); + await tester.pumpAndSettle(); - // `realunit-wallet://dashboard` must not navigate anywhere on a warm - // resume either — same no-op as the canonical open. - router.go('realunit-wallet://dashboard'); - await tester.pumpAndSettle(); + // `realunit-wallet://dashboard` must not navigate anywhere on a warm + // resume either — same no-op as the canonical open. + router.go('realunit-wallet://dashboard'); + await tester.pumpAndSettle(); - expect(currentPath(router), '/settings'); - expect(find.byKey(const Key('settings')), findsOneWidget); - expect(find.byKey(const Key('dashboard')), findsNothing); - }, - ); + expect(currentPath(router), '/settings'); + expect(find.byKey(const Key('settings')), findsOneWidget); + expect(find.byKey(const Key('dashboard')), findsNothing); + }); testWidgets( 'warm resume: a crafted scheme URL carrying a real path does not navigate ' @@ -457,34 +461,31 @@ void main() { }, ); - testWidgets( - 'warm resume: a crafted path-carrying scheme URL over a pushed ' - 'extra-required route neither navigates nor crashes', - (tester) async { - final router = await pump(tester); - router.go('/dashboard'); - await tester.pumpAndSettle(); + testWidgets('warm resume: a crafted path-carrying scheme URL over a pushed ' + 'extra-required route neither navigates nor crashes', (tester) async { + final router = await pump(tester); + router.go('/dashboard'); + await tester.pumpAndSettle(); - // Backgrounding on a pushed extra-required route is the everyday case - // (e.g. /buyPaymentDetails while paying in the banking app). - unawaited(router.push('/details', extra: 'payment')); - await tester.pumpAndSettle(); - expect(find.text('payment'), findsOneWidget); + // Backgrounding on a pushed extra-required route is the everyday case + // (e.g. /buyPaymentDetails while paying in the banking app). + unawaited(router.push('/details', extra: 'payment')); + await tester.pumpAndSettle(); + expect(find.text('payment'), findsOneWidget); - // The crafted URL is rewritten to the canonical path-less open and ends - // in the onException no-op. A currentLocation pin instead would rebuild - // /details via `go` with a null extra and crash the builder cast. - router.go('realunit-wallet://open/settings'); - await tester.pumpAndSettle(); + // The crafted URL is rewritten to the canonical path-less open and ends + // in the onException no-op. A currentLocation pin instead would rebuild + // /details via `go` with a null extra and crash the builder cast. + router.go('realunit-wallet://open/settings'); + await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(find.text('payment'), findsOneWidget); - expect(router.canPop(), isTrue); - router.pop(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('dashboard')), findsOneWidget); - }, - ); + expect(tester.takeException(), isNull); + expect(find.text('payment'), findsOneWidget); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dashboard')), findsOneWidget); + }); testWidgets( 'cold start on a crafted scheme URL to an auth path still boots to /home ' @@ -511,4 +512,45 @@ void main() { expect(currentPath(router), '/dashboard'); expect(find.byKey(const Key('dashboard')), findsOneWidget); }); + + group('extractReferralInviteCode', () { + test('reads the code from a hierarchical custom-scheme invite URL', () { + expect( + extractReferralInviteCode( + Uri.parse('realunit-wallet://invite/AbCd1234'), + ), + 'AbCd1234', + ); + }); + + test('reads the code from an opaque custom-scheme invite URL', () { + expect( + extractReferralInviteCode(Uri.parse('realunit-wallet:invite/AbCd1234')), + 'AbCd1234', + ); + }); + + test('reads the code from an https App Link', () { + expect( + extractReferralInviteCode( + Uri.parse('https://realunit.app/invite/AbCd1234'), + ), + 'AbCd1234', + ); + }); + + test('returns null for the canonical open and for other https paths', () { + expect(extractReferralInviteCode(Uri.parse(appLinkUrl)), isNull); + expect( + extractReferralInviteCode(Uri.parse('https://realunit.app/')), + isNull, + ); + expect( + extractReferralInviteCode( + Uri.parse('https://realunit.app/confirm-aktionariat/'), + ), + isNull, + ); + }); + }); }