diff --git a/packages/espressocash_app/assets/icons/star-background.svg b/packages/espressocash_app/assets/icons/star-background.svg new file mode 100644 index 0000000000..a63fff83f6 --- /dev/null +++ b/packages/espressocash_app/assets/icons/star-background.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/espressocash_app/assets/icons/star.svg b/packages/espressocash_app/assets/icons/star.svg index a63fff83f6..e6a127a1dc 100644 --- a/packages/espressocash_app/assets/icons/star.svg +++ b/packages/espressocash_app/assets/icons/star.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/espressocash_app/assets/icons/xmark.svg b/packages/espressocash_app/assets/icons/xmark.svg new file mode 100644 index 0000000000..80cc55ce87 --- /dev/null +++ b/packages/espressocash_app/assets/icons/xmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/espressocash_app/assets/images/2x/profile_graphic.png b/packages/espressocash_app/assets/images/2x/profile_graphic.png index 3e90547af3..57db913ca1 100644 Binary files a/packages/espressocash_app/assets/images/2x/profile_graphic.png and b/packages/espressocash_app/assets/images/2x/profile_graphic.png differ diff --git a/packages/espressocash_app/assets/images/face_frame.png b/packages/espressocash_app/assets/images/face_frame.png new file mode 100644 index 0000000000..35882f2dd0 Binary files /dev/null and b/packages/espressocash_app/assets/images/face_frame.png differ diff --git a/packages/espressocash_app/assets/images/profile_graphic.png b/packages/espressocash_app/assets/images/profile_graphic.png index 7b42c59266..e4affcace2 100644 Binary files a/packages/espressocash_app/assets/images/profile_graphic.png and b/packages/espressocash_app/assets/images/profile_graphic.png differ diff --git a/packages/espressocash_app/ios/Podfile b/packages/espressocash_app/ios/Podfile index f4547e2c15..e62eecdf55 100644 --- a/packages/espressocash_app/ios/Podfile +++ b/packages/espressocash_app/ios/Podfile @@ -1,3 +1,7 @@ +# https://github.com/juliansteenbakker/mobile_scanner/issues/1165#issuecomment-2314423274 +# Set the Firebase SDK version +$FirebaseSDKVersion = '10.29.0' + # Uncomment this line to define a global platform for your project platform :ios, '15.0' diff --git a/packages/espressocash_app/ios/Podfile.lock b/packages/espressocash_app/ios/Podfile.lock index 814675c17b..59c5b93f64 100644 --- a/packages/espressocash_app/ios/Podfile.lock +++ b/packages/espressocash_app/ios/Podfile.lock @@ -1,37 +1,41 @@ PODS: - - Firebase/CoreOnly (10.28.0): - - FirebaseCore (= 10.28.0) - - Firebase/Installations (10.28.0): + - camera_avfoundation (0.0.1): + - Flutter + - face_camera (0.0.1): + - Flutter + - Firebase/CoreOnly (10.29.0): + - FirebaseCore (= 10.29.0) + - Firebase/Installations (10.29.0): - Firebase/CoreOnly - - FirebaseInstallations (~> 10.28.0) - - Firebase/RemoteConfig (10.28.0): + - FirebaseInstallations (~> 10.29.0) + - Firebase/RemoteConfig (10.29.0): - Firebase/CoreOnly - - FirebaseRemoteConfig (~> 10.28.0) - - firebase_app_installations (0.3.0-3): - - Firebase/Installations (= 10.28.0) + - FirebaseRemoteConfig (~> 10.29.0) + - firebase_app_installations (0.3.1-1): + - Firebase/Installations (= 10.29.0) - firebase_core - Flutter - - firebase_core (3.2.0): - - Firebase/CoreOnly (= 10.28.0) + - firebase_core (3.4.0): + - Firebase/CoreOnly (= 10.29.0) - Flutter - - firebase_remote_config (5.0.3): - - Firebase/RemoteConfig (= 10.28.0) + - firebase_remote_config (5.1.0): + - Firebase/RemoteConfig (= 10.29.0) - firebase_core - Flutter - FirebaseABTesting (10.29.0): - FirebaseCore (~> 10.0) - - FirebaseCore (10.28.0): + - FirebaseCore (10.29.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) - FirebaseCoreInternal (10.29.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseInstallations (10.28.0): + - FirebaseInstallations (10.29.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) - - FirebaseRemoteConfig (10.28.0): + - FirebaseRemoteConfig (10.29.0): - FirebaseABTesting (~> 10.0) - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) @@ -53,6 +57,13 @@ PODS: - Flutter - flutter_secure_storage (6.0.0): - Flutter + - google_mlkit_commons (0.7.1): + - Flutter + - MLKitVision + - google_mlkit_face_detection (0.11.0): + - Flutter + - google_mlkit_commons + - GoogleMLKit/FaceDetection (~> 6.0.0) - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -60,6 +71,9 @@ PODS: - GoogleMLKit/BarcodeScanning (6.0.0): - GoogleMLKit/MLKitCore - MLKitBarcodeScanning (~> 5.0.0) + - GoogleMLKit/FaceDetection (6.0.0): + - GoogleMLKit/MLKitCore + - MLKitFaceDetection (~> 5.0.0) - GoogleMLKit/MLKitCore (6.0.0): - MLKitCommon (~> 11.0.0) - GoogleToolboxForMac/Defines (4.2.1) @@ -86,10 +100,10 @@ PODS: - Flutter - integration_test (0.0.1): - Flutter - - Intercom (17.1.2) + - Intercom (17.3.0) - intercom_flutter (9.0.0): - Flutter - - Intercom (= 17.1.2) + - Intercom (= 17.3.0) - Mixpanel-swift (4.2.0): - Mixpanel-swift/Complete (= 4.2.0) - Mixpanel-swift/Complete (4.2.0) @@ -107,13 +121,16 @@ PODS: - GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0) - GoogleUtilitiesComponents (~> 1.0) - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitFaceDetection (5.0.0): + - MLKitCommon (~> 11.0) + - MLKitVision (~> 7.0) - MLKitVision (7.0.0): - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) - MLImage (= 1.0.0-beta5) - MLKitCommon (~> 11.0) - - mobile_scanner (5.1.1): + - mobile_scanner (5.2.1): - Flutter - GoogleMLKit/BarcodeScanning (~> 6.0.0) - nanopb (2.30910.0): @@ -136,11 +153,11 @@ PODS: - Ramp - rive_common (0.0.1): - Flutter - - Sentry/HybridSDK (8.29.0) - - sentry_flutter (8.3.0): + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.29.0) + - Sentry/HybridSDK (= 8.35.1) - share (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -149,16 +166,16 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS - - "sqlite3 (3.46.0+1)": - - "sqlite3/common (= 3.46.0+1)" - - "sqlite3/common (3.46.0+1)" - - "sqlite3/dbstatvtab (3.46.0+1)": + - "sqlite3 (3.46.1+1)": + - "sqlite3/common (= 3.46.1+1)" + - "sqlite3/common (3.46.1+1)" + - "sqlite3/dbstatvtab (3.46.1+1)": - sqlite3/common - - "sqlite3/fts5 (3.46.0+1)": + - "sqlite3/fts5 (3.46.1+1)": - sqlite3/common - - "sqlite3/perf-threadsafe (3.46.0+1)": + - "sqlite3/perf-threadsafe (3.46.1+1)": - sqlite3/common - - "sqlite3/rtree (3.46.0+1)": + - "sqlite3/rtree (3.46.1+1)": - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter @@ -173,6 +190,8 @@ PODS: - Flutter DEPENDENCIES: + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - face_camera (from `.symlinks/plugins/face_camera/ios`) - firebase_app_installations (from `.symlinks/plugins/firebase_app_installations/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_remote_config (from `.symlinks/plugins/firebase_remote_config/ios`) @@ -180,6 +199,8 @@ DEPENDENCIES: - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - intercom_flutter (from `.symlinks/plugins/intercom_flutter/ios`) @@ -220,6 +241,7 @@ SPEC REPOS: - MLImage - MLKitBarcodeScanning - MLKitCommon + - MLKitFaceDetection - MLKitVision - nanopb - OrderedSet @@ -228,6 +250,10 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" + face_camera: + :path: ".symlinks/plugins/face_camera/ios" firebase_app_installations: :path: ".symlinks/plugins/firebase_app_installations/ios" firebase_core: @@ -242,6 +268,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + google_mlkit_commons: + :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_face_detection: + :path: ".symlinks/plugins/google_mlkit_face_detection/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -286,21 +316,25 @@ CHECKOUT OPTIONS: :tag: 4.0.1 SPEC CHECKSUMS: - Firebase: 5121c624121af81cbc81df3bda414b3c28c4f3c3 - firebase_app_installations: 81d9c2623601d3195b334e6e19bea0c801b8c4b3 - firebase_core: a9d0180d5285527884d07a41eb4a9ec9ed12cdb6 - firebase_remote_config: 5f92bfc62c3ef2c657bf3d703ffa4be29082280f + camera_avfoundation: dd002b0330f4981e1bbcb46ae9b62829237459a4 + face_camera: 9473f8c80e20d67bd2d946ed573ccee168d31186 + Firebase: cec914dab6fd7b1bd8ab56ea07ce4e03dd251c2d + firebase_app_installations: e6e79f8544e1d0d6bc4d7f46f3faf07bb39256d8 + firebase_core: e0f7606ab915d75a4177464ba363fa38000cf340 + firebase_remote_config: 6fefa476fc38b9c730c53d110f0403d5da66a1e2 FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe - FirebaseCore: 857dc1c6dd1255675047404d8466f7dfaac5d779 + FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 - FirebaseInstallations: 60c1d3bc1beef809fd1ad1189a8057a040c59f2e - FirebaseRemoteConfig: f0879a8dccf4e8905716ed849569130efaeab3e2 + FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd + FirebaseRemoteConfig: 48ef3f243742a8d72422ccfc9f986e19d7de53fd FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + google_mlkit_commons: 96aaca445520311b84a2da013dedf3427fe4cc69 + google_mlkit_face_detection: b760d6035222630f347352b3b13f4a23ea9fb994 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 @@ -308,16 +342,17 @@ SPEC CHECKSUMS: GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 - Intercom: 5f6d8c6f82a79ff6e3316029f71578c7ecdf1250 - intercom_flutter: 55f6371b5e745fb4588ac19d6ba9a5e673320c8f + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + Intercom: d6033516c223ca82fb3a905bc286d2ab75bb5a98 + intercom_flutter: ed91ded8495a89793850f4980b51c6b69cea2ccd Mixpanel-swift: e5dd85295923e6a875acf17ccbab8d2ecb10ea65 mixpanel_flutter: ed1e5eaea382cbbaf655d20e23478211727ee011 MLImage: 1824212150da33ef225fbd3dc49f184cf611046c MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1 + MLKitFaceDetection: 7c0e8bf09ddd27105da32d088fca978a99fc30cc MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1 - mobile_scanner: 8564358885a9253c43f822435b70f9345c87224f + mobile_scanner: 131a34df36b024cc53457809fb991700f16f72d7 nanopb: 438bc412db1928dac798aa6fd75726007be04262 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c @@ -327,16 +362,16 @@ SPEC CHECKSUMS: Ramp: 3f843fb75cac12ad40842afa3226bae36dc93521 ramp_flutter: aac85dee8bc93b5f7563c909ba15a37727fe3fe8 rive_common: cbbac3192af00d7341f19dae2f26298e9e37d99e - Sentry: 016de45ee5ce5fca2a829996f1bfafeb5e62e8b4 - sentry_flutter: 5fb57c5b7e6427a9dc1fedde4269eb65823982d4 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 share: 0b2c3e82132f5888bccca3351c504d0003b3b410 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b uni_links: d97da20c7701486ba192624d99bffaaffcfc298a url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe -PODFILE CHECKSUM: c2878e103071f1a812f2f4e4c83a762aef964055 +PODFILE CHECKSUM: fd4186da23e2d2ef26e7c0b13a7d08e792d772c1 -COCOAPODS: 1.13.0 +COCOAPODS: 1.15.2 diff --git a/packages/espressocash_app/lib/data/db/db.dart b/packages/espressocash_app/lib/data/db/db.dart index 2132660290..b5427aaea1 100644 --- a/packages/espressocash_app/lib/data/db/db.dart +++ b/packages/espressocash_app/lib/data/db/db.dart @@ -233,6 +233,9 @@ class OffRampOrderRows extends Table with AmountMixin, EntityMixin { } enum OnRampOrderStatus { + waitingUserVerification, // Kyc + waitingPartnerReview, // KYC + rejected, // KYC waitingForDeposit, depositExpired, waitingForPartner, @@ -262,6 +265,9 @@ enum OffRampOrderStatus { processingRefund, // MG waitingForRefundBridge, // MG refunded, // MG + waitingUserVerification, // Kyc + waitingPartnerReview, // KYC + rejected, // KYC } class OutgoingDlnPaymentRows extends Table with EntityMixin, TxStatusMixin { diff --git a/packages/espressocash_app/lib/features/accounts/services/account_service.dart b/packages/espressocash_app/lib/features/accounts/services/account_service.dart index e74e61b528..5a0e5fcf29 100644 --- a/packages/espressocash_app/lib/features/accounts/services/account_service.dart +++ b/packages/espressocash_app/lib/features/accounts/services/account_service.dart @@ -59,7 +59,7 @@ class AccountService extends ChangeNotifier if (_value == null) return; _analyticsManager.setWalletAddress(null); - Sentry.configureScope((scope) => scope.removeExtra('walletAddress')); + Sentry.configureScope((scope) => scope.removeContexts('walletAddress')); await _storage.deleteAll(); await sl.dropScope(authScope); @@ -69,7 +69,7 @@ class AccountService extends ChangeNotifier Future _processLogIn(MyAccount account) async { Sentry.configureScope( - (scope) => scope.setExtra('walletAddress', account.address), + (scope) => scope.setContexts('walletAddress', account.address), ); _analyticsManager.setWalletAddress(account.address); diff --git a/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart index 09458a12ed..2403e00ac4 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart @@ -34,7 +34,7 @@ class CpActivityTile extends StatelessWidget { return ListTile( onTap: onTap, - contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 30), leading: showIcon ? SizedBox.square( dimension: 42, diff --git a/packages/espressocash_app/lib/features/activities/widgets/kyc_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/kyc_tile.dart new file mode 100644 index 0000000000..76f235e27f --- /dev/null +++ b/packages/espressocash_app/lib/features/activities/widgets/kyc_tile.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/button.dart'; +import '../../../ui/colors.dart'; +import '../../kyc_sharing/services/kyc_service.dart'; +import '../../kyc_sharing/utils/kyc_utils.dart'; +import '../../kyc_sharing/widgets/kyc_status_icon.dart'; +import '../../ramp/partners/kyc/widgets/launch.dart'; +import '../../ramp_partner/models/ramp_type.dart'; + +class KycTile extends StatelessWidget { + const KycTile({ + super.key, + required this.title, + required this.timestamp, + this.incomingAmount, + this.outgoingAmount, + this.onTap, + required this.preOrder, + required this.rampType, + }); + + final String title; + final String timestamp; + final String? incomingAmount; + final String? outgoingAmount; + final VoidCallback? onTap; + final PreOrderData? preOrder; + final RampType rampType; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: sl(), + builder: (context, user, _) => user == null + ? const SizedBox.shrink() + : _KycTileContent( + status: user.kycStatus, + title: title, + timestamp: timestamp, + incomingAmount: incomingAmount, + outgoingAmount: outgoingAmount, + preOrder: preOrder, + rampType: rampType, + ), + ); +} + +class _KycTileContent extends StatelessWidget { + const _KycTileContent({ + required this.status, + required this.title, + required this.timestamp, + required this.incomingAmount, + required this.outgoingAmount, + required this.preOrder, + required this.rampType, + }); + + final ValidationStatus status; + final String title; + final String timestamp; + final String? incomingAmount; + final String? outgoingAmount; + final PreOrderData? preOrder; + final RampType rampType; + + @override + Widget build(BuildContext context) { + final incomingAmount = this.incomingAmount; + final outgoingAmount = this.outgoingAmount; + + return Container( + margin: const EdgeInsets.only(right: 10, left: 10, bottom: 6), + padding: const EdgeInsets.only(top: 6, right: 20, left: 20, bottom: 26), + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(30)), + ), + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: KycStatusIcon(status, height: 42), + title: Row( + children: [ + Expanded( + child: Text( + title, + style: _titleStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (incomingAmount != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text('+$incomingAmount', style: _inAmountStyle), + ), + if (outgoingAmount != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text('-$outgoingAmount', style: _titleStyle), + ), + ], + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + status.subtitle(context), + style: _subtitleStyle, + ), + Text(timestamp, style: _subtitleStyle), + ], + ), + ), + Text( + status.description(context), + textAlign: TextAlign.center, + style: _subtitleStyle, + ), + const SizedBox(height: 16), + CpButton( + minWidth: 180, + text: status.buttonTitle(context, rampType), + onPressed: switch (rampType) { + RampType.onRamp => () => + context.launchKycOnRamp(preOrder: preOrder), + RampType.offRamp => () => + context.launchKycOffRamp(preOrder: preOrder), + }, + ), + ], + ), + ); + } +} + +extension on ValidationStatus { + String subtitle(BuildContext context) => switch (this) { + ValidationStatus.approved => context.l10n.verified, + ValidationStatus.rejected => context.l10n.failed, + ValidationStatus.pending || + ValidationStatus.unverified || + ValidationStatus.unspecified => + context.l10n.activities_lblInProgress, + }; + + String description(BuildContext context) => switch (this) { + ValidationStatus.approved => context.l10n.kycTileDescriptionApproved, + ValidationStatus.pending => context.l10n.kycTileDescriptionPending, + ValidationStatus.rejected => context.l10n.kycTileDescriptionRejected, + ValidationStatus.unverified || + ValidationStatus.unspecified => + context.l10n.kycTileDescriptionUnverified, + }; + + String buttonTitle(BuildContext context, RampType rampType) => switch (this) { + ValidationStatus.approved => rampType == RampType.onRamp + ? context.l10n.continueDeposit + : context.l10n.continueWithdrawal, + ValidationStatus.unverified || + ValidationStatus.unspecified => + 'Continue Verification', + ValidationStatus.pending => context.l10n.seeDetails, + ValidationStatus.rejected => context.l10n.retryVerification, + }; +} + +const _titleStyle = TextStyle( + fontSize: 16, + letterSpacing: .23, + color: Colors.white, + fontWeight: FontWeight.w600, +); + +const _inAmountStyle = TextStyle( + fontSize: 16, + letterSpacing: .23, + color: CpColors.greenColor, + fontWeight: FontWeight.w500, +); + +const _subtitleStyle = TextStyle( + fontSize: 14, + color: Colors.white, + letterSpacing: .19, +); diff --git a/packages/espressocash_app/lib/features/activities/widgets/off_ramp_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/off_ramp_tile.dart index 50969cf57d..3a2b0e3302 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/off_ramp_tile.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/off_ramp_tile.dart @@ -7,9 +7,12 @@ import '../../../l10n/l10n.dart'; import '../../../utils/extensions.dart'; import '../../conversion_rates/widgets/extensions.dart'; import '../../ramp/screens/off_ramp_order_screen.dart'; +import '../../ramp/services/off_ramp_order_service.dart'; import '../../ramp/widgets/off_ramp_order_details.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../models/activity.dart'; import 'activity_tile.dart'; +import 'kyc_tile.dart'; class OffRampTile extends StatelessWidget { const OffRampTile({ @@ -24,23 +27,72 @@ class OffRampTile extends StatelessWidget { @override Widget build(BuildContext context) => OffRampOrderDetails( orderId: activity.id, - builder: (context, order) => CpActivityTile( - title: context.l10n.activities_lblWithdraw, - incomingAmount: order?.receiveAmount?.format( - context.locale, - maxDecimals: 2, - ), - icon: Assets.icons.paymentIcon.svg(), - status: switch (order?.status) { - OffRampOrderStatus.completed => CpActivityTileStatus.success, - OffRampOrderStatus.failure => CpActivityTileStatus.failure, - OffRampOrderStatus.refunded => CpActivityTileStatus.canceled, - // ignore: avoid-wildcard-cases-with-enums, check if needed - _ => CpActivityTileStatus.inProgress, - }, - timestamp: context.formatDate(activity.created), - onTap: () => OffRampOrderScreen.push(context, id: activity.id), - showIcon: showIcon, + builder: (context, order) => + order?.status == OffRampOrderStatus.waitingUserVerification + ? _KycTile(activity: activity, order: order) + : _ActivityTile( + order: order, + activity: activity, + showIcon: showIcon, + ), + ); +} + +class _KycTile extends StatelessWidget { + const _KycTile({ + required this.activity, + required this.order, + }); + + final OffRampActivity activity; + final OffRampOrder? order; + + @override + Widget build(BuildContext context) => KycTile( + title: context.l10n.activities_lblWithdraw, + timestamp: context.formatDate(activity.created), + outgoingAmount: order?.amount.format( + context.locale, + maxDecimals: 2, + ), + preOrder: ( + preOrderId: activity.id, + preAmount: order?.receiveAmount, + ), + rampType: RampType.offRamp, + ); +} + +class _ActivityTile extends StatelessWidget { + const _ActivityTile({ + required this.activity, + required this.order, + required this.showIcon, + }); + + final OffRampActivity activity; + final OffRampOrder? order; + final bool showIcon; + + @override + Widget build(BuildContext context) => CpActivityTile( + title: context.l10n.activities_lblWithdraw, + incomingAmount: order?.receiveAmount?.format( + context.locale, + maxDecimals: 2, ), + icon: Assets.icons.paymentIcon.svg(), + status: switch (order?.status) { + OffRampOrderStatus.completed => CpActivityTileStatus.success, + OffRampOrderStatus.failure || + OffRampOrderStatus.rejected => + CpActivityTileStatus.failure, + OffRampOrderStatus.refunded => CpActivityTileStatus.canceled, + // ignore: avoid-wildcard-cases-with-enums, check if needed + _ => CpActivityTileStatus.inProgress, + }, + timestamp: context.formatDate(activity.created), + onTap: () => OffRampOrderScreen.push(context, id: activity.id), + showIcon: showIcon, ); } diff --git a/packages/espressocash_app/lib/features/activities/widgets/on_ramp_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/on_ramp_tile.dart index 9e21029392..78a9e071ae 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/on_ramp_tile.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/on_ramp_tile.dart @@ -8,8 +8,10 @@ import '../../../utils/extensions.dart'; import '../../conversion_rates/widgets/extensions.dart'; import '../../ramp/screens/on_ramp_order_screen.dart'; import '../../ramp/widgets/on_ramp_order_details.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../models/activity.dart'; import 'activity_tile.dart'; +import 'kyc_tile.dart'; class OnRampTile extends StatelessWidget { const OnRampTile({ @@ -24,30 +26,80 @@ class OnRampTile extends StatelessWidget { @override Widget build(BuildContext context) => OnRampOrderDetails( orderId: activity.id, - builder: (context, order) => CpActivityTile( - title: context.l10n.activities_lblAddCash, - icon: Assets.icons.paymentIcon.svg(), - status: switch (order?.status) { - OnRampOrderStatus.depositExpired || - OnRampOrderStatus.failure => - CpActivityTileStatus.failure, - OnRampOrderStatus.completed => CpActivityTileStatus.success, - OnRampOrderStatus.waitingForDeposit || - OnRampOrderStatus.waitingForPartner || - OnRampOrderStatus.pending || - OnRampOrderStatus.preProcessing || - OnRampOrderStatus.postProcessing || - OnRampOrderStatus.waitingForBridge || - null => - CpActivityTileStatus.inProgress, - }, - timestamp: context.formatDate(activity.created), - incomingAmount: order?.receiveAmount?.format( - context.locale, - maxDecimals: 2, - ), - onTap: () => OnRampOrderScreen.push(context, id: order?.id ?? ''), - showIcon: showIcon, + builder: (context, order) => + order?.status == OnRampOrderStatus.waitingUserVerification + ? _KycTile(activity: activity, order: order) + : _ActivityTile( + order: order, + activity: activity, + showIcon: showIcon, + ), + ); +} + +class _KycTile extends StatelessWidget { + const _KycTile({ + required this.activity, + required this.order, + }); + + final OnRampActivity activity; + final OnRampOrder? order; + + @override + Widget build(BuildContext context) => KycTile( + title: context.l10n.activities_lblAddCash, + timestamp: context.formatDate(activity.created), + incomingAmount: order?.receiveAmount?.format( + context.locale, + maxDecimals: 2, + ), + preOrder: ( + preOrderId: activity.id, + preAmount: order?.manualDeposit?.transferAmount, + ), + rampType: RampType.onRamp, + ); +} + +class _ActivityTile extends StatelessWidget { + const _ActivityTile({ + required this.activity, + required this.order, + required this.showIcon, + }); + + final OnRampActivity activity; + final OnRampOrder? order; + final bool showIcon; + + @override + Widget build(BuildContext context) => CpActivityTile( + title: context.l10n.activities_lblAddCash, + icon: Assets.icons.paymentIcon.svg(), + status: switch (order?.status) { + OnRampOrderStatus.depositExpired || + OnRampOrderStatus.failure || + OnRampOrderStatus.rejected => + CpActivityTileStatus.failure, + OnRampOrderStatus.completed => CpActivityTileStatus.success, + OnRampOrderStatus.waitingForDeposit || + OnRampOrderStatus.waitingUserVerification || + OnRampOrderStatus.waitingPartnerReview || + OnRampOrderStatus.waitingForPartner || + OnRampOrderStatus.pending || + OnRampOrderStatus.preProcessing || + OnRampOrderStatus.postProcessing || + OnRampOrderStatus.waitingForBridge || + null => + CpActivityTileStatus.inProgress, + }, + timestamp: context.formatDate(activity.created), + incomingAmount: order?.receiveAmount?.format( + context.locale, + maxDecimals: 2, ), + onTap: () => OnRampOrderScreen.push(context, id: order?.id ?? ''), + showIcon: showIcon, ); } diff --git a/packages/espressocash_app/lib/features/ambassador/screens/ambassador_result_screen.dart b/packages/espressocash_app/lib/features/ambassador/screens/ambassador_result_screen.dart index f282b34b77..3440950615 100644 --- a/packages/espressocash_app/lib/features/ambassador/screens/ambassador_result_screen.dart +++ b/packages/espressocash_app/lib/features/ambassador/screens/ambassador_result_screen.dart @@ -45,7 +45,7 @@ class AmbassadorResultScreen extends StatelessWidget { children: [ Align( alignment: Alignment.center, - child: Assets.icons.star.svg( + child: Assets.icons.starBackground.svg( fit: BoxFit.cover, color: _starColor, width: 600, diff --git a/packages/espressocash_app/lib/features/authenticated/widgets/home_add_cash.dart b/packages/espressocash_app/lib/features/authenticated/widgets/home_add_cash.dart index db7c285551..8f8a5a7eb7 100644 --- a/packages/espressocash_app/lib/features/authenticated/widgets/home_add_cash.dart +++ b/packages/espressocash_app/lib/features/authenticated/widgets/home_add_cash.dart @@ -10,8 +10,8 @@ import '../../../ui/button.dart'; import '../../../ui/colors.dart'; import '../../accounts/models/account.dart'; import '../../activities/services/tx_updater.dart'; -import '../../ramp/models/ramp_type.dart'; import '../../ramp/widgets/ramp_buttons.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import 'balance_amount.dart'; import 'home_app_bar.dart'; import 'refresh_balance_wrapper.dart'; diff --git a/packages/espressocash_app/lib/features/feature_flags/services/feature_flags_manager.dart b/packages/espressocash_app/lib/features/feature_flags/services/feature_flags_manager.dart index 7aefef16c3..fcd724f0f6 100644 --- a/packages/espressocash_app/lib/features/feature_flags/services/feature_flags_manager.dart +++ b/packages/espressocash_app/lib/features/feature_flags/services/feature_flags_manager.dart @@ -29,10 +29,12 @@ class FeatureFlagsManager implements Disposable { bool isMoneygramAccessEnabled() => _remoteConfig.getBool(FeatureFlag.moneygram.name); + bool isBrijEnabled() => _remoteConfig.getBool(FeatureFlag.brij.name); + @override void onDispose() { _subscription?.cancel(); } } -enum FeatureFlag { moneygram } +enum FeatureFlag { moneygram, brij } diff --git a/packages/espressocash_app/lib/features/fees/services/fee_calculator.dart b/packages/espressocash_app/lib/features/fees/services/fee_calculator.dart index 79d20f5877..a763428597 100644 --- a/packages/espressocash_app/lib/features/fees/services/fee_calculator.dart +++ b/packages/espressocash_app/lib/features/fees/services/fee_calculator.dart @@ -35,7 +35,7 @@ class FeeCalculator { RampPartner.rampNetwork => fees.withdrawFeePercentage.rampNetwork, RampPartner.kado => fees.withdrawFeePercentage.kado, - RampPartner.moneygram => 0, + RampPartner.brij || RampPartner.moneygram => 0, }; final percentageFeeAmount = (amount * percentageFee).ceil(); diff --git a/packages/espressocash_app/lib/features/kyc_sharing/data/kyc_repository.dart b/packages/espressocash_app/lib/features/kyc_sharing/data/kyc_repository.dart new file mode 100644 index 0000000000..7f1a387f17 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/data/kyc_repository.dart @@ -0,0 +1,174 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart' hide Order; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../utils/errors.dart'; +import '../../accounts/auth_scope.dart'; +import '../../accounts/models/ec_wallet.dart'; + +const config = AppConfig.production(); + +@Singleton(scope: authScope) +class KycRepository extends ChangeNotifier { + KycRepository(this._ecWallet); + + final ECWallet _ecWallet; + late KycUserClient _kycUserClient; + + Future? _clientInitialization; + + Future _init() => _clientInitialization ??= Future(() async { + try { + _kycUserClient = _createClient(); + await _kycUserClient.init( + walletAddress: _ecWallet.publicKey.toString(), + ); + } on Exception catch (exception) { + _clientInitialization = null; + reportError(exception); + rethrow; + } + }); + + Future _initWrapper(Future Function() operation) async { + await _init(); + + return operation(); + } + + KycUserClient _createClient() => KycUserClient( + config: config, + sign: (data) async { + final signature = + await _ecWallet.sign([Uint8List.fromList(data.toList())]); + + return signature.first; + }, + ); + + Future _getUserData() => _kycUserClient.getUserData( + userPK: _kycUserClient.authPublicKey, + secretKey: _kycUserClient.rawSecretKey, + ); + + Future fetchUser() async { + try { + return await _initWrapper(_getUserData); + } on Exception { + return null; + } + } + + Future updateUserData({ + Email? email, + Phone? phone, + Name? name, + Document? document, + BankInfo? bankInfo, + BirthDate? birthDate, + Selfie? selfie, + }) async { + await _initWrapper( + () => _kycUserClient.setData( + email: email, + phone: phone, + name: name, + dob: birthDate, + document: document, + bankInfo: bankInfo, + selfie: selfie, + ), + ); + } + + Future initEmailVerification({required String emailId}) => + _initWrapper(() => _kycUserClient.initEmailValidation(dataId: emailId)); + + Future verifyEmail({ + required String code, + required String dataId, + }) async { + await _initWrapper( + () => _kycUserClient.validateEmail(code: code, dataId: dataId), + ); + } + + Future initPhoneVerification({required String phoneId}) => + _initWrapper(() => _kycUserClient.initPhoneValidation(dataId: phoneId)); + + Future verifyPhone({ + required String code, + required String dataId, + }) => + _initWrapper( + () => _kycUserClient.validatePhone(code: code, dataId: dataId), + ); + + Future initKycVerification({ + required String nameId, + required String birthDateId, + required String documentId, + required String selfieImageId, + }) async { + await _initWrapper( + () => _kycUserClient.initDocumentValidation( + nameId: nameId, + birthDateId: birthDateId, + documentId: documentId, + selfieImageId: selfieImageId, + ), + ); + } + + Future createOnRampOrder({ + required String cryptoAmount, + required String cryptoCurrency, + required String fiatAmount, + required String fiatCurrency, + required String partnerPK, + }) => + _initWrapper( + () => _kycUserClient.createOnRampOrder( + partnerPK: partnerPK, + cryptoAmount: cryptoAmount, + cryptoCurrency: cryptoCurrency, + fiatAmount: fiatAmount, + fiatCurrency: fiatCurrency, + ), + ); + + Future createOffRampOrder({ + required String cryptoAmount, + required String cryptoCurrency, + required String fiatAmount, + required String fiatCurrency, + required String partnerPK, + required String bankName, + required String bankAccount, + }) => + _initWrapper( + () => _kycUserClient.createOffRampOrder( + partnerPK: partnerPK, + cryptoAmount: cryptoAmount, + cryptoCurrency: cryptoCurrency, + fiatAmount: fiatAmount, + fiatCurrency: fiatCurrency, + bankName: bankName, + bankAccount: bankAccount, + ), + ); + + Future fetchOrder(String orderId) => _initWrapper( + () => _kycUserClient.getOrder(orderId: OrderId.fromOrderId(orderId)), + ); + + Future grantPartnerAccess(String partnerPk) => _initWrapper( + () => _kycUserClient.grantPartnerAccess(partnerPk), + ); + + Future grantValidatorAccess() => _initWrapper( + () => _kycUserClient.grantPartnerAccess(config.verifierAuthPk), + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/models/document_type.dart b/packages/espressocash_app/lib/features/kyc_sharing/models/document_type.dart new file mode 100644 index 0000000000..1007eb70ff --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/models/document_type.dart @@ -0,0 +1,36 @@ +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +enum DocumentType { + bvn('BVN', 'BVN'), + nin('NIN', 'NIN'), + ninV2('NIN V2', 'NIN_V2'), + ninSlip('NIN SLIP', 'NIN_SLIP'), + driversLicense('Driver\'s License', 'DRIVERS_LICENSE'), + voterId('Voter ID', 'VOTER_ID'); + + const DocumentType(this.name, this.value); + + final String name; + final String value; +} + +extension DocumentTypeExtension on DocumentType { + IdType toIdType() => switch (this) { + DocumentType.bvn || + DocumentType.nin || + DocumentType.ninV2 || + DocumentType.ninSlip => + IdType.other, + DocumentType.driversLicense => IdType.driverLicense, + DocumentType.voterId => IdType.voterId, + }; +} + +extension IdTypeExtension on IdType { + DocumentType? toDocumentType() => switch (this) { + IdType.driverLicense => DocumentType.driversLicense, + IdType.voterId => DocumentType.voterId, + // ignore: avoid-wildcard-cases-with-enums, check if needed + _ => null, + }; +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/models/kyc_order_status.dart b/packages/espressocash_app/lib/features/kyc_sharing/models/kyc_order_status.dart new file mode 100644 index 0000000000..7634b55856 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/models/kyc_order_status.dart @@ -0,0 +1,22 @@ +enum KycOrderStatus { + pending('PENDING'), + accepted('ACCEPTED'), + rejected('REJECTED'), + completed('COMPLETED'), + failed('FAILED'), + unknown('UNKNOWN'); + + const KycOrderStatus(this.value); + + final String value; + + static KycOrderStatus fromString(String status) { + for (final KycOrderStatus kycStatus in KycOrderStatus.values) { + if (kycStatus.value == status) { + return kycStatus; + } + } + + return KycOrderStatus.unknown; + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/bank_account_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/bank_account_screen.dart new file mode 100644 index 0000000000..d78c3ef371 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/bank_account_screen.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_utils.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class BankAccountScreen extends StatefulWidget { + const BankAccountScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const BankAccountScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => _BankAccountScreenState(); +} + +class _BankAccountScreenState extends State { + final _bankAccountNumberController = TextEditingController(); + final _bankCodeController = TextEditingController(); + final _bankNameController = TextEditingController(); + + bool get _isValid => + _bankAccountNumberController.text.trim().isNotEmpty && + _bankCodeController.text.trim().isNotEmpty && + _bankNameController.text.trim().isNotEmpty; + + Future _handleSubmitted() async { + final success = await runWithLoader( + context, + () async { + try { + await sl().updateBankInfo( + bankAccountNumber: _bankAccountNumberController.text, + bankCode: _bankCodeController.text, + bankName: _bankNameController.text, + ); + + return true; + } on Exception { + if (!mounted) return false; + showCpErrorSnackbar( + context, + message: context.l10n.failedToUpdateData, + ); + + return false; + } + }, + ); + + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + @override + void initState() { + super.initState(); + + final user = sl().value; + + _bankAccountNumberController.text = user?.accountNumber ?? ''; + _bankCodeController.text = user?.bankCode ?? ''; + _bankNameController.text = user?.bankName ?? ''; + } + + @override + void dispose() { + _bankAccountNumberController.dispose(); + _bankCodeController.dispose(); + _bankNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.bankAccount, + children: [ + const SizedBox(height: 30), + Text( + context.l10n.bankAccountInfoCorrectText, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: 0.19, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 20), + KycTextField( + controller: _bankAccountNumberController, + inputType: TextInputType.name, + placeholder: context.l10n.accountNumber, + ), + const SizedBox(height: 16), + KycTextField( + controller: _bankCodeController, + inputType: TextInputType.name, + placeholder: context.l10n.bankCode, + ), + const SizedBox(height: 16), + KycTextField( + controller: _bankNameController, + inputType: TextInputType.name, + placeholder: context.l10n.bankName, + ), + const SizedBox(height: 16), + const Spacer(), + ListenableBuilder( + listenable: Listenable.merge([ + _bankAccountNumberController, + _bankCodeController, + _bankNameController, + ]), + builder: (context, child) => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: _isValid ? _handleSubmitted : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/basic_information_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/basic_information_screen.dart new file mode 100644 index 0000000000..0642e3dabc --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/basic_information_screen.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/dob_text_field.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/radio_button.dart'; +import '../../../ui/snackbar.dart'; +import '../../country_picker/models/country.dart'; +import '../../country_picker/widgets/country_picker.dart'; +import '../models/document_type.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_utils.dart'; +import '../widgets/document_picker.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class BasicInformationScreen extends StatefulWidget { + const BasicInformationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const BasicInformationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => _BasicInformationScreenState(); +} + +class _BasicInformationScreenState extends State { + final _idNumberController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _dobController = TextEditingController(); + + bool _isShareData = false; + + Country? _country; + DocumentType? _idType; + + bool get _isValid { + final DateTime? dob = _parseDate(_dobController.text); + + return _firstNameController.text.trim().isNotEmpty && + _lastNameController.text.trim().isNotEmpty && + dob != null && + !dob.isAfter(DateTime.now()) && + _idNumberController.text.trim().isNotEmpty && + _isShareData && + _idType != null && + _country != null; + } + + @override + void initState() { + super.initState(); + _initializeUserData(); + } + + @override + void dispose() { + _idNumberController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _dobController.dispose(); + + super.dispose(); + } + + void _initializeUserData() { + final user = sl().value; + final dob = user?.dob; + _country = Country.findByCode(user?.countryCode ?? ''); + _firstNameController.text = user?.firstName ?? ''; + _lastNameController.text = user?.lastName ?? ''; + _dobController.text = + dob != null ? DateFormat('dd/MM/yyyy').format(dob) : ''; + _idType = user?.documentType?.toDocumentType(); + _idNumberController.text = user?.documentNumber ?? ''; + } + + Future _handleSubmitted() async { + final success = await runWithLoader( + context, + () async { + try { + final DateTime? dob = _parseDate(_dobController.text); + final countryCode = _country?.code; + final idTypeValue = _idType?.value; + + if (countryCode == null || idTypeValue == null) { + throw Exception(); + } + + await sl().updateBasicInfo( + firstName: _firstNameController.text, + lastName: _lastNameController.text, + dob: dob, + countryCode: countryCode, + idType: _idType, + idNumber: _idNumberController.text, + ); + + if (!mounted) return false; + + return true; + } on Exception { + if (!mounted) return false; + + showCpErrorSnackbar( + context, + message: context.l10n.failedToUpdateData, + ); + + return false; + } + }, + ); + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + DateTime? _parseDate(String text) { + if (text.isEmpty) return null; + + final DateFormat dateFormat = DateFormat('dd/MM/yyyy'); + final date = dateFormat.tryParse(text); + + final List parts = text.split('/'); + + return parts.length == 3 && parts[2].length == 4 ? date : null; + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.basicInformation, + children: [ + const SizedBox(height: 30), + CountryPicker( + backgroundColor: CpColors.blackGreyColor, + country: _country, + onSubmitted: (country) => setState(() => _country = country), + ), + const SizedBox(height: 16), + KycTextField( + controller: _firstNameController, + inputType: TextInputType.name, + placeholder: context.l10n.firstName, + ), + const SizedBox(height: 16), + KycTextField( + controller: _lastNameController, + inputType: TextInputType.name, + placeholder: context.l10n.lastName, + ), + const SizedBox(height: 16), + CpDobTextField( + controller: _dobController, + placeholder: context.l10n.dateOfBirth, + ), + const SizedBox(height: 16), + DocumentPicker( + type: _idType, + onSubmitted: (idType) => setState(() => _idType = idType), + ), + const SizedBox(height: 16), + KycTextField( + controller: _idNumberController, + inputType: TextInputType.text, + placeholder: context.l10n.idNumber, + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () => setState(() => _isShareData = !_isShareData), + child: Row( + children: [ + CpRadioButton( + value: _isShareData, + onChanged: (value) => setState(() => _isShareData = value), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.allowShareDataText, + style: const TextStyle( + fontSize: 14, + height: 1.5, + letterSpacing: 0.19, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + const Spacer(), + ListenableBuilder( + listenable: Listenable.merge([ + _firstNameController, + _lastNameController, + _dobController, + ]), + builder: (context, child) => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: _isValid ? _handleSubmitted : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/document_type_picker_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/document_type_picker_screen.dart new file mode 100644 index 0000000000..9cbc303a4e --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/document_type_picker_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../ui/app_bar.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/theme.dart'; +import '../models/document_type.dart'; + +class DocumentTypePickerScreen extends StatelessWidget { + const DocumentTypePickerScreen({ + super.key, + this.initial, + }); + + final DocumentType? initial; + + @override + Widget build(BuildContext context) => CpTheme.dark( + child: Scaffold( + backgroundColor: CpColors.blackGreyColor, + appBar: CpAppBar( + title: Text(context.l10n.selectIdMethod.toUpperCase()), + ), + body: _Content(initial: initial), + ), + ); +} + +class _Content extends StatefulWidget { + const _Content({this.initial}); + + final DocumentType? initial; + + @override + State<_Content> createState() => _ContentState(); +} + +class _ContentState extends State<_Content> { + DocumentType? _selectedType; + + final _types = DocumentType.values; + + @override + void initState() { + super.initState(); + + _selectedType = widget.initial; + } + + @override + Widget build(BuildContext context) => ListView.builder( + padding: EdgeInsets.only( + top: 40, + left: 20, + right: 20, + bottom: MediaQuery.paddingOf(context).bottom, + ), + itemCount: _types.length, + itemExtent: _tileHeight, + itemBuilder: (BuildContext context, int index) { + final type = _types[index]; + final selected = type == _selectedType; + + return DecoratedBox( + decoration: selected + ? const ShapeDecoration( + color: CpColors.blackTextFieldBackgroundColor, + shape: StadiumBorder(), + ) + : const BoxDecoration(), + child: ListTile( + dense: true, + title: Text( + type.name.toUpperCase(), + style: TextStyle(fontSize: selected ? 19 : 17), + ), + selectedColor: Colors.white, + shape: selected ? const StadiumBorder() : null, + onTap: () => Navigator.pop(context, type), + ), + ); + }, + ); +} + +const _tileHeight = 46.0; diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/email_confirmation_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/email_confirmation_screen.dart new file mode 100644 index 0000000000..4b6f7df19b --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/email_confirmation_screen.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_exception.dart'; +import '../utils/kyc_utils.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class EmailConfirmationScreen extends StatefulWidget { + const EmailConfirmationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const EmailConfirmationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => + _EmailConfirmationScreenState(); +} + +class _EmailConfirmationScreenState extends State { + final _controller = TextEditingController(); + + bool get _isValid => _controller.text.length == 6; + + Future _handleConfirm() async { + final success = await runWithLoader( + context, + () async { + try { + await sl().verifyEmail(code: _controller.text); + + return true; + } on KycException catch (error) { + if (!mounted) return false; + + final message = switch (error) { + KycInvalidCode() => context.l10n.wrongVerificationCode, + _ => context.l10n.tryAgainLater, + }; + + showCpErrorSnackbar(context, message: message); + + return false; + } + }, + ); + + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.emailVerification, + children: [ + const SizedBox(height: 20), + Text( + context.l10n + .checkEmailText(sl().value?.getEmail ?? ''), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + const SizedBox(height: 40), + KycTextField( + controller: _controller, + inputType: TextInputType.number, + placeholder: context.l10n.enterVerificationCode, + ), + const Spacer(), + ListenableBuilder( + listenable: _controller, + builder: (context, child) => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: _isValid ? _handleConfirm : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/email_verification_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/email_verification_screen.dart new file mode 100644 index 0000000000..9e62038041 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/email_verification_screen.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/button.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../../../utils/email.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_exception.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class EmailVerificationScreen extends StatefulWidget { + const EmailVerificationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const EmailVerificationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => + _EmailVerificationScreenState(); +} + +class _EmailVerificationScreenState extends State { + final _emailController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _sendEmail() async { + final success = await runWithLoader( + context, + () async { + try { + await sl() + .initEmailVerification(email: _emailController.text); + + return true; + } on KycException catch (error) { + if (!mounted) return false; + + final message = switch (error) { + KycInvalidEmail() => context.l10n.invalidEmail, + _ => context.l10n.failedToSendVerificationCode, + }; + + showCpErrorSnackbar(context, message: message); + + return false; + } + }, + ); + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.emailVerification, + children: [ + const SizedBox(height: 20), + Text( + context.l10n.enterEmailHintText, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + const SizedBox(height: 40), + KycTextField( + controller: _emailController, + inputType: TextInputType.emailAddress, + placeholder: context.l10n.emailAddress, + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: _emailController, + builder: (context, child) => CpButton( + minWidth: 250, + text: context.l10n.sendVerificationCode, + onPressed: _emailController.text.isValidEmail ? _sendEmail : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/identity_verification_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/identity_verification_screen.dart new file mode 100644 index 0000000000..2e7198d2ea --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/identity_verification_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../../gen/assets.gen.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/app_bar.dart'; +import '../../../ui/back_button.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/info_list.dart'; +import '../../../ui/theme.dart'; +import 'kyc_camera_screen.dart'; + +class IdentityVerificationScreen extends StatelessWidget { + const IdentityVerificationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const IdentityVerificationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + Widget build(BuildContext context) => CpTheme.black( + child: Scaffold( + appBar: CpAppBar( + leading: const CpBackButton(), + title: Text(context.l10n.identityVerification.toUpperCase()), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SafeArea( + top: false, + child: Column( + children: [ + Assets.images.profileGraphic.image(height: 80), + const SizedBox(height: 20), + Text( + context.l10n.identityVerificationDescription, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + const SizedBox(height: 40), + const Expanded(child: _Timeline()), + CpBottomButton( + horizontalPadding: 16, + text: context.l10n.startSelfieVerification, + onPressed: () async { + final success = await KycCameraScreen.push(context); + + if (!context.mounted) return; + if (!success) return; + + Navigator.pop(context, true); + }, + ), + ], + ), + ), + ), + ), + ); +} + +class _Timeline extends StatelessWidget { + const _Timeline(); + + @override + Widget build(BuildContext context) { + final items = [ + CpInfoListItem(subtitle: context.l10n.identityInstruction1), + CpInfoListItem(subtitle: context.l10n.identityInstruction2), + CpInfoListItem(subtitle: context.l10n.identityInstruction3), + ]; + + return CpInfoList(items: items); + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_camera_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_camera_screen.dart new file mode 100644 index 0000000000..9c577a0be2 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_camera_screen.dart @@ -0,0 +1,223 @@ +import 'dart:io'; + +import 'package:face_camera/face_camera.dart'; +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../gen/assets.gen.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../../../ui/theme.dart'; +import '../services/kyc_service.dart'; + +class KycCameraScreen extends StatefulWidget { + const KycCameraScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const KycCameraScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => _KycCameraScreenState(); +} + +class _KycCameraScreenState extends State { + File? _capturedImage; + + late FaceCameraController _controller; + + Future _handleSubmitted() async { + final success = await runWithLoader( + context, + () async { + try { + final service = sl(); + await service.updateSelfiePhoto(photoSelfie: _capturedImage); + await service + .initDocumentValidation(); // TODO(dev): move this in background + + return true; + } on Exception { + if (!mounted) return false; + showCpErrorSnackbar( + context, + message: context.l10n.failedToUpdateData, + ); + + return false; + } + }, + ); + + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + @override + void initState() { + super.initState(); + _controller = FaceCameraController( + autoCapture: false, + defaultCameraLens: CameraLens.front, + onCapture: (image) { + setState(() => _capturedImage = image); + }, + ); + } + + @override + Widget build(BuildContext context) => CpTheme.black( + child: Scaffold( + body: SafeArea( + top: false, + child: Stack( + children: [ + Center( + child: Builder( + builder: (context) { + final capturedImage = _capturedImage; + + return capturedImage != null + ? _ResultView( + capturedImage: capturedImage, + onRetakePressed: () async { + await _controller.startImageStream(); + + if (!mounted) return; + + setState(() => _capturedImage = null); + }, + onSubmitPressed: _handleSubmitted, + ) + : _CameraView(_controller); + }, + ), + ), + Align( + alignment: Alignment.topRight, + child: IconButton( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top + 16, + right: 24, + ), + icon: const Icon(Icons.close, size: 28), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + ], + ), + ), + ), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _CameraView extends StatelessWidget { + const _CameraView(this._controller); + + final FaceCameraController _controller; + + @override + Widget build(BuildContext context) => Stack( + alignment: Alignment.bottomCenter, + children: [ + SmartFaceCamera( + controller: _controller, + indicatorShape: IndicatorShape.image, + indicatorAssetImage: Assets.images.faceFrame.path, + showControls: false, + ), + Padding( + padding: const EdgeInsets.only(bottom: 30), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: CpColors.yellowColor, + width: 3, + ), + ), + ), + SizedBox( + width: 60, + height: 60, + child: CpButton( + text: '', + onPressed: _controller.captureImage, + ), + ), + ], + ), + ), + ], + ); +} + +class _ResultView extends StatelessWidget { + const _ResultView({ + required this.capturedImage, + required this.onRetakePressed, + required this.onSubmitPressed, + }); + + final File capturedImage; + final VoidCallback onRetakePressed; + final VoidCallback onSubmitPressed; + + @override + Widget build(BuildContext context) => Stack( + alignment: Alignment.bottomCenter, + children: [ + Transform.flip( + flipX: false, + child: Image.file( + capturedImage, + height: double.maxFinite, + fit: BoxFit.fitHeight, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 40, + vertical: 16, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + CpButton( + variant: CpButtonVariant.light, + width: double.infinity, + text: context.l10n.retakeSelfie, + onPressed: onRetakePressed, + ), + const SizedBox(height: 16), + CpButton( + width: double.infinity, + text: context.l10n.submit, + onPressed: onSubmitPressed, + ), + ], + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_description_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_description_screen.dart new file mode 100644 index 0000000000..c63cc93b27 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_description_screen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/colors.dart'; +import '../../ramp_partner/models/ramp_type.dart'; +import '../widgets/kyc_page.dart'; + +class KycDescriptionScreen extends StatelessWidget { + const KycDescriptionScreen({super.key, required this.rampType}); + + static Future push(BuildContext context, RampType rampType) => + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => KycDescriptionScreen(rampType: rampType), + ), + ) + .then((result) => result ?? false); + + final RampType rampType; + + @override + Widget build(BuildContext context) => KycPage( + title: switch (rampType) { + RampType.onRamp => context.l10n.ramp_btnAddCash, + RampType.offRamp => context.l10n.ramp_btnCashOut, + } + .toUpperCase(), + children: [ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: MarkdownBody( + data: switch (rampType) { + RampType.onRamp => + context.l10n.onRampKycInitialDescription.toUpperCase(), + RampType.offRamp => + context.l10n.offRampKycInitialDescription.toUpperCase(), + }, + styleSheet: MarkdownStyleSheet( + em: _markdownStyle.copyWith(color: CpColors.yellowColor), + p: _markdownStyle, + ), + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + context.l10n.reVerificationNotice, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + ), + const Spacer(), + CpBottomButton( + horizontalPadding: 16, + text: context.l10n.begin, + onPressed: () => Navigator.pop(context, true), + ), + ], + ); +} + +const _markdownStyle = TextStyle( + fontStyle: FontStyle.normal, + fontSize: 32, + fontWeight: FontWeight.w900, + letterSpacing: 0.25, + height: 1, +); diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_status_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_status_screen.dart new file mode 100644 index 0000000000..fe325da927 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/kyc_status_screen.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../di.dart'; +import '../../../gen/assets.gen.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/app_bar.dart'; +import '../../../ui/back_button.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/info_list.dart'; +import '../../../ui/theme.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_utils.dart'; + +class KycStatusScreen extends StatefulWidget { + const KycStatusScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const KycStatusScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => _KycStatusScreenState(); +} + +class _KycStatusScreenState extends State { + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: sl(), + builder: (context, userData, child) => userData == null + ? const CircularProgressIndicator() + : CpTheme.black( + child: Scaffold( + appBar: CpAppBar( + leading: const CpBackButton(), + title: + Text(context.l10n.identityVerification.toUpperCase()), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + top: false, + child: Column( + children: [ + Assets.images.profileGraphic.image(height: 80), + const SizedBox(height: 20), + _getTitle(userData.kycStatus), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _getDescriptionText(userData.kycStatus), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + ), + const SizedBox(height: 40), + Expanded(child: _getTimeline(userData.kycStatus)), + _getButton(userData.kycStatus), + ], + ), + ), + ), + ), + ), + ); + + Widget _getTitle(ValidationStatus status) { + final title = switch (status) { + ValidationStatus.approved => ( + text: context.l10n.verified, + color: CpColors.greenColor, + ), + ValidationStatus.pending => ( + text: context.l10n.pendingApproval, + color: CpColors.lightButtonBackgroundColor, + ), + ValidationStatus.rejected => ( + text: context.l10n.verificationFailed, + color: CpColors.alertRedColor, + ), + ValidationStatus.unspecified || ValidationStatus.unverified => ( + text: context.l10n.verificationNotStarted, + color: CpColors.lightGreyColor, + ), + }; + + return MarkdownBody( + data: title.text.toUpperCase(), + styleSheet: MarkdownStyleSheet( + p: _titleStyle.copyWith(color: title.color), + em: _titleStyle.copyWith(color: CpColors.yellowColor), + textAlign: WrapAlignment.center, + ), + ); + } + + String _getDescriptionText(ValidationStatus status) => switch (status) { + ValidationStatus.approved => context.l10n.kycStatusApprovedDescription, + ValidationStatus.pending => context.l10n.kycStatusPendingDescription, + ValidationStatus.unspecified || + ValidationStatus.unverified || + ValidationStatus.rejected => + context.l10n.kycStatusFailedDescription, + }; + + Widget _getTimeline(ValidationStatus status) { + final timelineItems = switch (status) { + ValidationStatus.pending => [ + CpInfoListItem( + subtitle: context.l10n.kycTimelinePendingItem1, + variant: CpInfoListVariant.light, + ), + CpInfoListItem( + subtitle: context.l10n.kycTimelinePendingItem2, + variant: CpInfoListVariant.light, + ), + CpInfoListItem( + subtitle: context.l10n.kycTimelinePendingItem3, + ), + ], + ValidationStatus.unverified || + ValidationStatus.unspecified || + ValidationStatus.rejected => + [ + CpInfoListItem( + subtitle: context.l10n.kycTimelineRejectedItem1, + variant: CpInfoListVariant.light, + ), + CpInfoListItem( + subtitle: context.l10n.kycTimelineRejectedItem2, + variant: CpInfoListVariant.light, + ), + ], + ValidationStatus.approved => [ + CpInfoListItem( + subtitle: context.l10n.kycTimelineApprovedItem1, + variant: CpInfoListVariant.light, + ), + CpInfoListItem( + subtitle: context.l10n.kycTimelineApprovedItem2, + variant: CpInfoListVariant.light, + ), + CpInfoListItem( + subtitle: context.l10n.kycTimelineApprovedItem3, + variant: CpInfoListVariant.light, + ), + ], + }; + + return CpInfoList(items: timelineItems); + } + + Widget _getButton(ValidationStatus status) => switch (status) { + ValidationStatus.approved => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: () => Navigator.pop(context, true), + ), + ValidationStatus.pending => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.returnToDashboard, + onPressed: () => Navigator.pop(context, false), + ), + ValidationStatus.rejected => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.retry, + // TODO(vsumin): Add retry logic + onPressed: () => Navigator.pop(context, false), + ), + ValidationStatus.unverified || + ValidationStatus.unspecified => + const SizedBox.shrink(), + }; +} + +const _titleStyle = TextStyle( + fontStyle: FontStyle.normal, + fontSize: 32, + fontWeight: FontWeight.w900, + letterSpacing: 0.25, + height: 1, +); diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/manage_data_access_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/manage_data_access_screen.dart new file mode 100644 index 0000000000..7b0ca831cc --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/manage_data_access_screen.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../../ui/app_bar.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/theme.dart'; +import '../widgets/kyc_button.dart'; + +class ManageDataAccessScreen extends StatelessWidget { + const ManageDataAccessScreen({super.key}); + + static void push(BuildContext context) => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ManageDataAccessScreen(), + ), + ); + + @override + Widget build(BuildContext context) => CpTheme.dark( + child: Scaffold( + backgroundColor: CpColors.blackGreyColor, + appBar: CpAppBar( + title: Text('Manage Data Access'.toUpperCase()), + ), + body: const _Content(), + ), + ); +} + +class _Content extends StatelessWidget { + const _Content(); + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only( + top: 40, + left: 40, + right: 40, + bottom: MediaQuery.paddingOf(context).bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text('Network partners'.toUpperCase()), + ), + const Spacer(), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(30)), + child: Material( + color: CpColors.blackGreyColor, + child: KycButton( + label: 'Delete All Data', + onPressed: () {}, + textColor: CpColors.dangerButtonTextColor, + backgroundColor: CpColors.dangerButtonBackground, + showIcon: false, + centerText: true, + ), + ), + ), + ], + ), + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_confirmation_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_confirmation_screen.dart new file mode 100644 index 0000000000..45998a5b6c --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_confirmation_screen.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_exception.dart'; +import '../utils/kyc_utils.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class PhoneConfirmationScreen extends StatefulWidget { + const PhoneConfirmationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const PhoneConfirmationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => + _PhoneConfirmationScreenState(); +} + +class _PhoneConfirmationScreenState extends State { + final _controller = TextEditingController(); + + bool get _isValid => _controller.text.length == 6; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleConfirm() async { + final success = await runWithLoader( + context, + () async { + try { + await sl().verifyPhone(code: _controller.text); + + return true; + } on KycException catch (error) { + if (!mounted) return false; + + final message = switch (error) { + KycInvalidCode() => context.l10n.wrongVerificationCode, + _ => context.l10n.tryAgainLater, + }; + + showCpErrorSnackbar(context, message: message); + + return false; + } + }, + ); + + if (!mounted) return; + if (success) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.phoneVerification, + children: [ + const SizedBox(height: 20), + Text( + context.l10n + .checkSmsText(sl().value?.getPhone ?? ''), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + const SizedBox(height: 40), + KycTextField( + controller: _controller, + inputType: TextInputType.number, + placeholder: context.l10n.enterVerificationCode, + ), + const Spacer(), + ListenableBuilder( + listenable: _controller, + builder: (context, child) => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: _isValid ? _handleConfirm : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_verification_screen.dart b/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_verification_screen.dart new file mode 100644 index 0000000000..e0e7fde0db --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/screens/phone_verification_screen.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/button.dart'; +import '../../../ui/loader.dart'; +import '../../../ui/snackbar.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_exception.dart'; +import '../widgets/kyc_page.dart'; +import '../widgets/kyc_text_field.dart'; + +class PhoneVerificationScreen extends StatefulWidget { + const PhoneVerificationScreen({super.key}); + + static Future push(BuildContext context) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => const PhoneVerificationScreen(), + ), + ) + .then((result) => result ?? false); + + @override + State createState() => _PhoneInputScreenState(); +} + +class _PhoneInputScreenState extends State { + final _numberController = TextEditingController(); + + bool get _isValid => RegExp( + r'^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$', + ).hasMatch(_numberController.text); + + Future _sendSms() async { + final success = await runWithLoader( + context, + () async { + try { + await sl() + .initPhoneVerification(phone: _numberController.text); + + return true; + } on KycException catch (error) { + if (!mounted) return false; + + final message = switch (error) { + KycInvalidPhone() => context.l10n.invalidPhone, + _ => context.l10n.failedToSendVerificationCode, + }; + + showCpErrorSnackbar(context, message: message); + + return false; + } + }, + ); + if (!mounted) return; + + if (success) Navigator.pop(context, true); + } + + @override + void dispose() { + _numberController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => KycPage( + title: context.l10n.phoneVerification, + children: [ + const SizedBox(height: 20), + Text( + context.l10n.enterPhoneNumberHintText, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 21 / 16, + letterSpacing: .19, + ), + ), + const SizedBox(height: 40), + KycTextField( + controller: _numberController, + inputType: TextInputType.phone, + placeholder: context.l10n.phoneNumber, + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: _numberController, + builder: (context, child) => CpButton( + minWidth: 250, + text: context.l10n.sendVerificationCode, + onPressed: _isValid ? _sendSms : null, + ), + ), + ], + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/services/kyc_service.dart b/packages/espressocash_app/lib/features/kyc_sharing/services/kyc_service.dart new file mode 100644 index 0000000000..340f9d2672 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/services/kyc_service.dart @@ -0,0 +1,220 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +import 'dart:async'; +import 'dart:io'; + +import 'package:dfunc/dfunc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../../di.dart'; +import '../../accounts/auth_scope.dart'; +import '../../feature_flags/services/feature_flags_manager.dart'; +import '../data/kyc_repository.dart'; +import '../models/document_type.dart'; +import '../utils/kyc_exception.dart'; +import '../utils/kyc_utils.dart'; + +@Singleton(scope: authScope) +class KycSharingService extends ValueNotifier { + KycSharingService(this._kycRepository) : super(null); + + final KycRepository _kycRepository; + + StreamSubscription? _pollingSubscription; + + @PostConstruct() + void init() { + if (!sl().isBrijEnabled()) return; + + _initializeKyc(); + } + + Future _initializeKyc() async { + await fetchUserData(); + + if (value?.kycStatus == ValidationStatus.pending) { + _subscribe(); + } + } + + Future fetchUserData() async { + final user = await _kycRepository.fetchUser(); + + value = user; + notifyListeners(); + } + + void _subscribe() { + _unsubscribe(); + + _pollingSubscription = Stream.periodic(const Duration(seconds: 15)) + .startWith(null) + .exhaustMap( + (_) => fetchUserData() + .timeout( + const Duration(seconds: 8), + onTimeout: () => null, + ) + .asStream() + .onErrorReturn(null), + ) + .takeWhile((_) => value?.kycStatus == ValidationStatus.pending) + .listen((_) {}); + } + + void _unsubscribe() { + _pollingSubscription?.cancel(); + _pollingSubscription = null; + } + + Future updateBasicInfo({ + String? firstName, + String? lastName, + DateTime? dob, + String? idNumber, + DocumentType? idType, + String? countryCode, + }) async { + await _kycRepository.grantValidatorAccess(); + + await _kycRepository.updateUserData( + name: Name( + firstName: firstName ?? '', + lastName: lastName ?? '', + id: value?.name?.first.id ?? '', + ), + birthDate: dob?.let( + (e) => BirthDate( + value: e, + id: value?.birthDate?.first.id ?? '', + ), + ), + document: Document( + type: idType?.toIdType() ?? IdType.other, + number: idNumber ?? '', + countryCode: countryCode ?? '', + id: value?.document?.first.id ?? '', + ), + ); + + await fetchUserData(); + } + + Future updateBankInfo({ + required String bankAccountNumber, + required String bankCode, + String? bankName, + }) async { + await _kycRepository.updateUserData( + bankInfo: BankInfo( + accountNumber: bankAccountNumber, + bankCode: bankCode, + bankName: bankName ?? '', + id: value?.bankInfo?.first.id ?? '', + ), + ); + + await fetchUserData(); + } + + Future initDocumentValidation() async { + await _kycRepository.initKycVerification( + nameId: value?.name?.first.id ?? '', + birthDateId: value?.birthDate?.first.id ?? '', + documentId: value?.document?.first.id ?? '', + selfieImageId: value?.selfie?.first.id ?? '', + ); + + await fetchUserData(); + _subscribe(); + } + + Future updateSelfiePhoto({File? photoSelfie}) async { + await _kycRepository.updateUserData( + selfie: photoSelfie != null + ? Selfie( + value: await photoSelfie.readAsBytes(), + id: value?.selfie?.first.id ?? '', + ) + : null, + ); + + await fetchUserData(); + } + + Future initEmailVerification({required String email}) async { + try { + await _kycRepository.grantValidatorAccess(); + + await _kycRepository.updateUserData( + email: Email( + value: email, + id: value?.email?.first.id ?? '', + ), + ); + + await fetchUserData(); + + await _kycRepository.initEmailVerification( + emailId: value?.email?.first.id ?? '', + ); + } on Exception catch (exception) { + throw exception.toKycException(); + } + } + + Future verifyEmail({required String code}) async { + try { + await _kycRepository.verifyEmail( + code: code, + dataId: value?.email?.first.id ?? '', + ); + } on Exception catch (exception) { + throw exception.toKycException(); + } finally { + await fetchUserData(); + } + } + + Future initPhoneVerification({required String phone}) async { + try { + await _kycRepository.updateUserData( + phone: Phone( + value: phone, + id: value?.phone?.first.id ?? '', + ), + ); + + await fetchUserData(); + + await _kycRepository.initPhoneVerification( + phoneId: value?.phone?.first.id ?? '', + ); + } on Exception catch (exception) { + throw exception.toKycException(); + } + } + + Future verifyPhone({required String code}) async { + try { + await _kycRepository.verifyPhone( + code: code, + dataId: value?.phone?.first.id ?? '', + ); + } on Exception catch (exception) { + throw exception.toKycException(); + } finally { + await fetchUserData(); + } + } + + @override + @disposeMethod + void dispose() { + _unsubscribe(); + super.dispose(); + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_exception.dart b/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_exception.dart new file mode 100644 index 0000000000..b78aaa0b31 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_exception.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'kyc_exception.freezed.dart'; + +@freezed +sealed class KycException with _$KycException implements Exception { + const factory KycException.invalidCode() = KycInvalidCode; + const factory KycException.invalidEmail() = KycInvalidEmail; + const factory KycException.invalidPhone() = KycInvalidPhone; + const factory KycException.invalidData() = KycInvalidData; + const factory KycException.invalidToken() = KycInvalidToken; + const factory KycException.genericError() = KycGenericError; +} + +extension ErrorExt on Exception { + KycException toKycException() { + if (this is! DioException) { + return const KycException.genericError(); + } + + final dioException = this as DioException; + final message = (dioException.response?.data + as Map?)?['message'] as String?; + + return switch (message) { + 'invalid token' => const KycException.invalidToken(), + 'invalid email' => const KycException.invalidEmail(), + 'invalid phone' => const KycException.invalidPhone(), + 'invalid code' => const KycException.invalidCode(), + 'invalid data' => const KycException.invalidData(), + _ => const KycException.genericError(), + }; + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_utils.dart b/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_utils.dart new file mode 100644 index 0000000000..5211e89603 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/utils/kyc_utils.dart @@ -0,0 +1,77 @@ +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:dfunc/dfunc.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +extension ValidationStatusExtension on ValidationStatus { + bool get isApprovedOrPending => + this == ValidationStatus.approved || this == ValidationStatus.pending; + + bool get isUnspecified => this == ValidationStatus.unspecified; +} + +extension UserDataExtensions on UserData { + String? get firstName => name?.first.firstName.nullIfEmpty; + String? get lastName => name?.first.lastName.nullIfEmpty; + DateTime? get dob => birthDate?.first.value; + + String? get getEmail => email?.first.value.nullIfEmpty; + String? get getPhone => phone?.first.value.nullIfEmpty; + + IdType? get documentType => document?.first.type; + String? get documentNumber => document?.first.number.nullIfEmpty; + String? get countryCode => document?.first.countryCode.nullIfEmpty; + + Uint8List? get photo => selfie?.first.value.let(Uint8List.fromList); + + String? get bankCode => bankInfo?.first.bankCode.nullIfEmpty; + String? get bankName => bankInfo?.first.bankName.nullIfEmpty; + String? get accountNumber => bankInfo?.first.accountNumber.nullIfEmpty; + + ValidationStatus get kycStatus { + final statuses = [ + document?.first.status, + name?.first.status, + selfie?.first.status, + birthDate?.first.status, + ].whereNotNull().toList(); + + if (statuses.isEmpty) return ValidationStatus.unspecified; + + if (statuses.any((s) => s == ValidationStatus.rejected)) { + return ValidationStatus.rejected; + } + + if (statuses.any((s) => s == ValidationStatus.pending)) { + return ValidationStatus.pending; + } + + return statuses.every((s) => s == ValidationStatus.approved) + ? ValidationStatus.approved + : ValidationStatus.unspecified; + } + + ValidationStatus get phoneStatus => + phone?.first.status ?? ValidationStatus.unspecified; + ValidationStatus get emailStatus => + email?.first.status ?? ValidationStatus.unspecified; + + bool get hasBankInfo => + (bankInfo?.first.bankCode.isNotEmpty ?? false) && + (bankInfo?.first.accountNumber.isNotEmpty ?? false) && + (bankInfo?.first.bankName.isNotEmpty ?? false); +} + +extension StringNullIfEmpty on String { + String? get nullIfEmpty => this.isEmpty ? null : this; +} + +extension IdTypeExtension on IdType { + String get name => switch (this) { + IdType.voterId => 'Voter ID', + IdType.passport => 'Passport', + IdType.driverLicense => 'Driver License', + IdType.other => 'Other', + }; +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/document_picker.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/document_picker.dart new file mode 100644 index 0000000000..23fdf9e7a3 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/document_picker.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../../ui/colors.dart'; +import '../models/document_type.dart'; +import '../screens/document_type_picker_screen.dart'; + +class DocumentPicker extends StatelessWidget { + const DocumentPicker({ + super.key, + this.type, + required this.onSubmitted, + }); + + final DocumentType? type; + final ValueSetter onSubmitted; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: const ShapeDecoration( + color: CpColors.blackGreyColor, + shape: StadiumBorder(), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () async { + final DocumentType? updated = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DocumentTypePickerScreen(initial: type), + ), + ); + + if (context.mounted && updated != null) { + onSubmitted(updated); + } + }, + title: Text( + type?.name ?? 'Select ID Verification Method', + style: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 16, + color: Colors.white, + height: 1.2, + ), + ), + trailing: const Icon( + Icons.keyboard_arrow_down_outlined, + color: Colors.white, + size: 28, + ), + ), + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_button.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_button.dart new file mode 100644 index 0000000000..fc5e9629d4 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_button.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../../../ui/colors.dart'; +import 'kyc_status_widget.dart'; + +class KycButton extends StatelessWidget { + const KycButton({ + super.key, + required this.label, + this.description, + this.onPressed, + this.textColor = CpColors.lightGreyBackground, + this.iconColor, + this.backgroundColor, + this.showIcon = true, + this.centerText = false, + this.status, + }); + + final String label; + final String? description; + final VoidCallback? onPressed; + final Color textColor; + final Color? backgroundColor; + final Color? iconColor; + final bool showIcon; + final bool centerText; + final ValidationStatus? status; + + @override + Widget build(BuildContext context) { + final description = this.description; + final status = this.status; + + return ListTile( + tileColor: backgroundColor, + onTap: onPressed, + title: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + centerText ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Flexible( + child: Text( + label, + style: TextStyle( + color: textColor, + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + if (status != null) KycStatusWidget(status), + ], + ), + subtitle: description != null + ? Text( + description, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ) + : null, + trailing: showIcon ? Icon(Icons.chevron_right, color: iconColor) : null, + ); + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_flow.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_flow.dart new file mode 100644 index 0000000000..f94792c89f --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_flow.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/snackbar.dart'; +import '../../ramp_partner/models/ramp_type.dart'; +import '../screens/bank_account_screen.dart'; +import '../screens/basic_information_screen.dart'; +import '../screens/email_confirmation_screen.dart'; +import '../screens/email_verification_screen.dart'; +import '../screens/identity_verification_screen.dart'; +import '../screens/kyc_description_screen.dart'; +import '../screens/kyc_status_screen.dart'; +import '../screens/phone_confirmation_screen.dart'; +import '../screens/phone_verification_screen.dart'; +import '../services/kyc_service.dart'; +import '../utils/kyc_utils.dart'; + +typedef KycStepFunction = Future Function(BuildContext ctx); + +const List kycSteps = [ + BasicInformationScreen.push, + IdentityVerificationScreen.push, +]; + +const List emailSteps = [ + EmailVerificationScreen.push, + EmailConfirmationScreen.push, +]; + +const List phoneSteps = [ + PhoneVerificationScreen.push, + PhoneConfirmationScreen.push, +]; + +extension KycFlowExtension on BuildContext { + Future openKycFlow({required RampType rampType}) async { + final user = sl().value; + + if (user == null) { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + + return false; + } + + final kycProcessed = user.kycStatus.isApprovedOrPending; + + if (!kycProcessed) { + final success = await KycDescriptionScreen.push(this, rampType); + if (!success) return false; + } + + final emailValidated = user.emailStatus == ValidationStatus.approved; + + if (!emailValidated) { + if (!await openEmailFlow()) return false; + } + + final phoneValidated = user.phoneStatus == ValidationStatus.approved; + + if (!phoneValidated) { + if (!await openPhoneFlow()) return false; + } + + final hasBankInfo = user.hasBankInfo; + + if (!hasBankInfo) { + if (!await _navigateToScreen(BankAccountScreen.push)) return false; + } + + if (!kycProcessed) { + if (!await openBasicInfoFlow()) return false; + } + + if (user.kycStatus != ValidationStatus.approved) { + if (!await _navigateToScreen(KycStatusScreen.push)) return false; + } + + return true; + } + + Future openBasicInfoFlow() => _runFlow(kycSteps); + + Future openEmailFlow() => _runFlow(emailSteps); + + Future openPhoneFlow() => _runFlow(phoneSteps); + + Future _runFlow(List steps) async { + for (final step in steps) { + if (!await _navigateToScreen(step)) return false; + } + + return true; + } + + Future _navigateToScreen(KycStepFunction pushFunction) async { + final success = await pushFunction(this); + + return success ?? false; + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_page.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_page.dart new file mode 100644 index 0000000000..f971c9fe43 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_page.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import '../../../gen/assets.gen.dart'; +import '../../../ui/app_bar.dart'; +import '../../../ui/back_button.dart'; +import '../../../ui/theme.dart'; + +class KycPage extends StatelessWidget { + const KycPage({ + super.key, + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + Widget build(BuildContext context) => CpTheme.black( + child: Scaffold( + appBar: CpAppBar( + leading: const CpBackButton(), + title: Text(title.toUpperCase()), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: constraints.copyWith( + minHeight: constraints.maxHeight, + maxHeight: double.infinity, + ), + child: IntrinsicHeight( + child: Column( + children: [ + Assets.images.profileGraphic.image(height: 80), + Expanded( + child: SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: 40), + child: Column(children: children), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_icon.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_icon.dart new file mode 100644 index 0000000000..86cfa6e8f3 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_icon.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../../../ui/colors.dart'; +import '../../../gen/assets.gen.dart'; + +class KycStatusIcon extends StatelessWidget { + const KycStatusIcon( + this.status, { + super.key, + this.height = 16, + }); + + final ValidationStatus status; + final double height; + @override + Widget build(BuildContext context) => Stack( + alignment: Alignment.center, + children: [ + Assets.icons.star.svg( + color: status.backgroundColor, + height: height, + ), + Assets.icons.xmark.svg(height: height / 2), + ], + ); +} + +extension on ValidationStatus { + Color get backgroundColor { + switch (this) { + case ValidationStatus.approved: + return CpColors.greenColor; + case ValidationStatus.pending: + return CpColors.yellowColor; + case ValidationStatus.unspecified: + case ValidationStatus.unverified: + return CpColors.greyColor; + case ValidationStatus.rejected: + return CpColors.alertRedColor; + } + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_widget.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_widget.dart new file mode 100644 index 0000000000..d6883b4172 --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_status_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../../../ui/colors.dart'; +import '../../../l10n/l10n.dart'; +import 'kyc_status_icon.dart'; + +class KycStatusWidget extends StatelessWidget { + const KycStatusWidget(this.status, {super.key}); + + final ValidationStatus status; + + @override + Widget build(BuildContext context) { + Color backgroundColor; + String statusText; + switch (status) { + case ValidationStatus.approved: + backgroundColor = CpColors.greenColor; + statusText = context.l10n.verified; + case ValidationStatus.pending: + backgroundColor = CpColors.yellowColor; + statusText = context.l10n.pending; + case ValidationStatus.unspecified: + case ValidationStatus.unverified: + backgroundColor = CpColors.greyColor; + statusText = context.l10n.notVerified; + case ValidationStatus.rejected: + backgroundColor = CpColors.alertRedColor; + statusText = context.l10n.error; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + KycStatusIcon(status), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + color: backgroundColor, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_text_field.dart b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_text_field.dart new file mode 100644 index 0000000000..d6068eb7bc --- /dev/null +++ b/packages/espressocash_app/lib/features/kyc_sharing/widgets/kyc_text_field.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import '../../../ui/colors.dart'; +import '../../../ui/text_field.dart'; + +class KycTextField extends StatelessWidget { + const KycTextField({ + super.key, + required this.controller, + required this.inputType, + required this.placeholder, + this.readOnly = false, + }); + + final TextEditingController controller; + final TextInputType inputType; + final String placeholder; + final bool readOnly; + + @override + Widget build(BuildContext context) => CpTextField( + padding: const EdgeInsets.only( + top: 18, + bottom: 16, + left: 26, + right: 26, + ), + fontWeight: FontWeight.w500, + fontSize: 16, + controller: controller, + inputType: inputType, + textInputAction: TextInputAction.next, + backgroundColor: CpColors.lightGreyColor, + placeholder: placeholder, + placeholderColor: Colors.white, + textColor: Colors.white, + readOnly: readOnly, + ); +} diff --git a/packages/espressocash_app/lib/features/profile/screens/profile_screen.dart b/packages/espressocash_app/lib/features/profile/screens/profile_screen.dart index b92c0ca968..038610d71c 100644 --- a/packages/espressocash_app/lib/features/profile/screens/profile_screen.dart +++ b/packages/espressocash_app/lib/features/profile/screens/profile_screen.dart @@ -12,9 +12,11 @@ import '../../../di.dart'; import '../../../ui/clipboard.dart'; import '../../../ui/colors.dart'; import '../../accounts/models/account.dart'; +import '../../feature_flags/services/feature_flags_manager.dart'; import '../data/profile_repository.dart'; import '../widgets/ambassador_section.dart'; import '../widgets/help_section.dart'; +import '../widgets/kyc_section.dart'; import '../widgets/profile_section.dart'; import '../widgets/security_section.dart'; @@ -105,17 +107,24 @@ class ProfileScreen extends StatelessWidget { ], ), ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 24), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 24, + ), child: Column( children: [ - EditProfileSection(), - AmbassadorSection(), - SecuritySection(), - HelpSection(), - DangerSection(), - ShareSection(), - VersionSection(), + if (sl().isBrijEnabled()) ...[ + const KycSection(), + ] else ...[ + const EditProfileSection(), + ], + const AmbassadorSection(), + const SecuritySection(), + const HelpSection(), + const DangerSection(), + const ShareSection(), + const VersionSection(), ], ), ), diff --git a/packages/espressocash_app/lib/features/profile/widgets/kyc_section.dart b/packages/espressocash_app/lib/features/profile/widgets/kyc_section.dart new file mode 100644 index 0000000000..659353eee8 --- /dev/null +++ b/packages/espressocash_app/lib/features/profile/widgets/kyc_section.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../country_picker/models/country.dart'; +import '../../kyc_sharing/screens/bank_account_screen.dart'; +import '../../kyc_sharing/services/kyc_service.dart'; +import '../../kyc_sharing/utils/kyc_utils.dart'; +import '../../kyc_sharing/widgets/kyc_button.dart'; +import '../../kyc_sharing/widgets/kyc_flow.dart'; +import 'profile_section.dart'; + +class KycSection extends StatefulWidget { + const KycSection({super.key}); + + @override + State createState() => _KycSectionState(); +} + +class _KycSectionState extends State { + @override + void initState() { + super.initState(); + final kycService = sl(); + if (kycService.value == null) { + kycService.fetchUserData(); + } + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: sl(), + builder: (context, user, _) => + user == null ? const SizedBox.shrink() : _KycInfo(user: user), + ); +} + +class _KycInfo extends StatelessWidget { + const _KycInfo({required this.user}); + + final UserData user; + + @override + Widget build(BuildContext context) => ProfileSection( + title: context.l10n.accountProfile.toUpperCase(), + padding: const EdgeInsets.fromLTRB(8, 16, 2, 16), + actions: [ + KycButton( + label: context.l10n.editProfile, + description: _getUserDescription(user, context), + onPressed: context.openBasicInfoFlow, + status: user.kycStatus.isUnspecified ? null : user.kycStatus, + ), + if (user.hasBankInfo) + KycButton( + label: context.l10n.bankAccount, + description: _getBankDescription(user, context), + onPressed: () => BankAccountScreen.push(context), + ), + if (user.getEmail != null) + KycButton( + label: context.l10n.emailAddress, + description: user.getEmail, + onPressed: context.openEmailFlow, + status: user.emailStatus, + ), + if (user.getPhone != null) + KycButton( + label: context.l10n.phoneNumber, + description: user.getPhone, + onPressed: context.openPhoneFlow, + status: user.phoneStatus, + ), + // TODO(dev): hidden for now, still in development + // if (!user.kycStatus.isUnspecified) + // KycButton( + // label: context.l10n.manageDataAccess, + // onPressed: () => ManageDataAccessScreen.push(context), + // ), + ], + ); +} + +String? _getUserDescription(UserData user, BuildContext context) { + final items = [ + [user.firstName, user.lastName].whereType().join(' ').trim(), + if (user.dob case final dob?) + context.l10n.userDescriptionItem1Text(_formatDate(dob)), + if (user.documentType case final idType?) + context.l10n.userDescriptionItem2Text(idType.name), + if (user.documentNumber case final documentNumber?) + context.l10n.userDescriptionItem3Text(documentNumber), + ].where((s) => s.isNotEmpty); + + return items.isEmpty ? null : items.join('\n'); +} + +String? _getBankDescription(UserData user, BuildContext context) { + final country = Country.findByCode(user.countryCode ?? ''); + + final items = [ + if (country case final country?) + context.l10n.bankDescriptionItem1Text(country.name), + if (user.accountNumber case final accountNumber?) + context.l10n.bankDescriptionItem2Text(accountNumber), + if (user.bankCode case final bankCode?) + context.l10n.bankDescriptionItem3Text(bankCode), + ].where((s) => s.isNotEmpty); + + return items.isEmpty ? null : items.join('\n'); +} + +String _formatDate(DateTime date) => DateFormat('MMMM d, yyyy').format(date); diff --git a/packages/espressocash_app/lib/features/profile/widgets/profile_button.dart b/packages/espressocash_app/lib/features/profile/widgets/profile_button.dart index 2f8a37a0a9..755b4c0d9c 100644 --- a/packages/espressocash_app/lib/features/profile/widgets/profile_button.dart +++ b/packages/espressocash_app/lib/features/profile/widgets/profile_button.dart @@ -43,7 +43,6 @@ class ProfileButton extends StatelessWidget { subtitle: description != null ? Text( description, - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, diff --git a/packages/espressocash_app/lib/features/ramp/partners/coinflow/services/coinflow_off_ramp_order_watcher.dart b/packages/espressocash_app/lib/features/ramp/partners/coinflow/services/coinflow_off_ramp_order_watcher.dart index 4e4fbfffd3..73956f77f2 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/coinflow/services/coinflow_off_ramp_order_watcher.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/coinflow/services/coinflow_off_ramp_order_watcher.dart @@ -11,8 +11,8 @@ import '../../../../../data/db/db.dart'; import '../../../../accounts/models/ec_wallet.dart'; import '../../../../analytics/analytics_manager.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../models/ramp_watcher.dart'; import '../data/coinflow_api_client.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/coinflow/widgets/coinflow_link_listener.dart b/packages/espressocash_app/lib/features/ramp/partners/coinflow/widgets/coinflow_link_listener.dart index 1cef3fd603..692d0c859b 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/coinflow/widgets/coinflow_link_listener.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/coinflow/widgets/coinflow_link_listener.dart @@ -4,7 +4,7 @@ import '../../../../../config.dart'; import '../../../../../di.dart'; import '../../../../accounts/models/account.dart'; import '../../../../dynamic_links/widgets/dynamic_link_handler.dart'; -import '../../../models/ramp_type.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../widgets/ramp_buttons.dart'; import 'launch.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_off_ramp_order_watcher.dart b/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_off_ramp_order_watcher.dart index 9e18b4ad24..35467b9fa7 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_off_ramp_order_watcher.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_off_ramp_order_watcher.dart @@ -7,8 +7,8 @@ import 'package:rxdart/rxdart.dart'; import '../../../../../data/db/db.dart'; import '../../../../analytics/analytics_manager.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../models/ramp_watcher.dart'; import '../data/kado_api_client.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_on_ramp_order_watcher.dart b/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_on_ramp_order_watcher.dart index 279712ee46..099bcec162 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_on_ramp_order_watcher.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/kado/services/kado_on_ramp_order_watcher.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'package:drift/drift.dart'; - import 'package:injectable/injectable.dart'; import 'package:rxdart/rxdart.dart'; @@ -10,8 +9,8 @@ import '../../../../../../data/db/db.dart'; import '../../../../analytics/analytics_manager.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../models/ramp_watcher.dart'; import '../data/kado_api_client.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/kado/widgets/launch.dart b/packages/espressocash_app/lib/features/ramp/partners/kado/widgets/launch.dart index ae6a4329d6..e8529c5352 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/kado/widgets/launch.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/kado/widgets/launch.dart @@ -12,9 +12,9 @@ import '../../../../conversion_rates/services/amount_ext.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../tokens/token.dart'; import '../../../models/profile_data.dart'; -import '../../../models/ramp_type.dart'; import '../../../screens/off_ramp_order_screen.dart'; import '../../../screens/on_ramp_order_screen.dart'; import '../../../screens/ramp_amount_screen.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_off_ramp_order_service.dart b/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_off_ramp_order_service.dart new file mode 100644 index 0000000000..a9bf740130 --- /dev/null +++ b/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_off_ramp_order_service.dart @@ -0,0 +1,424 @@ +import 'dart:async'; + +import 'package:dfunc/dfunc.dart'; +import 'package:drift/drift.dart'; +import 'package:espressocash_api/espressocash_api.dart'; +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:solana/encoder.dart'; +import 'package:solana/solana.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../../config.dart'; +import '../../../../../data/db/db.dart'; +import '../../../../accounts/auth_scope.dart'; +import '../../../../accounts/models/ec_wallet.dart'; +import '../../../../analytics/analytics_manager.dart'; +import '../../../../currency/models/amount.dart'; +import '../../../../currency/models/currency.dart'; +import '../../../../kyc_sharing/data/kyc_repository.dart'; +import '../../../../kyc_sharing/models/kyc_order_status.dart'; +import '../../../../kyc_sharing/utils/kyc_utils.dart'; +import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; +import '../../../../tokens/token.dart'; +import '../../../../transactions/models/tx_results.dart'; +import '../../../../transactions/services/resign_tx.dart'; +import '../../../../transactions/services/tx_sender.dart'; + +@Singleton(scope: authScope) +class BrijOffRampOrderService implements Disposable { + BrijOffRampOrderService( + this._db, + this._kycRepository, + this._analytics, + this._ecClient, + this._sender, + this._account, + ); + + final ECWallet _account; + final MyDatabase _db; + final KycRepository _kycRepository; + final AnalyticsManager _analytics; + + final EspressoCashClient _ecClient; + final TxSender _sender; + + final Map> _subscriptions = {}; + final Map> _watchers = {}; + + @PostConstruct(preResolve: true) + Future init() async { + final query = _db.select(_db.offRampOrderRows) + ..where( + (tbl) => + tbl.status.isNotInValues([ + OffRampOrderStatus.completed, + OffRampOrderStatus.cancelled, + OffRampOrderStatus.refunded, + OffRampOrderStatus.rejected, + ]) & + tbl.partner.equalsValue(RampPartner.brij), + ); + + final orders = await query.get(); + + for (final order in orders) { + if (order.partner != RampPartner.brij) { + continue; + } + + _subscribe(order.id); + } + } + + void _subscribe(String orderId) { + _subscriptions[orderId] = (_db.select(_db.offRampOrderRows) + ..where((tbl) => tbl.id.equals(orderId))) + .watchSingleOrNull() + .whereNotNull() + .asyncExpand((order) { + switch (order.status) { + case OffRampOrderStatus.waitingPartnerReview: + _waitingPartnerReviewWatcher(order); + + return const Stream.empty(); + + case OffRampOrderStatus.creatingDepositTx: + final amount = CryptoAmount( + value: order.amount, + cryptoCurrency: const CryptoCurrency(token: Token.usdc), + ); + + return Stream.fromFuture( + _createTx( + amount: amount, + receiver: Ed25519HDPublicKey.fromBase58(order.depositAddress), + ), + ).onErrorReturn( + const OffRampOrderRowsCompanion( + status: Value(OffRampOrderStatus.depositError), + ), + ); + + case OffRampOrderStatus.depositTxReady: + return Stream.value( + const OffRampOrderRowsCompanion( + status: Value(OffRampOrderStatus.sendingDepositTx), + ), + ); + + case OffRampOrderStatus.sendingDepositTx: + final tx = SignedTx.decode(order.transaction) + .let((it) => (it, order.slot)); + + return Stream.fromFuture(_sendTx(tx)); + + case OffRampOrderStatus.waitingForPartner: + _waitingForPartnerWatcher(order); + + return const Stream.empty(); + + case OffRampOrderStatus.completed: + case OffRampOrderStatus.rejected: + case OffRampOrderStatus.cancelled: + _subscriptions.remove(orderId)?.cancel(); + + _watchers[orderId]?.cancel(); + _watchers.remove(orderId); + + return const Stream.empty(); + + case OffRampOrderStatus.waitingUserVerification: + case OffRampOrderStatus.preProcessing: + case OffRampOrderStatus.postProcessing: + case OffRampOrderStatus.ready: + case OffRampOrderStatus.insufficientFunds: + case OffRampOrderStatus.processingRefund: + case OffRampOrderStatus.waitingForRefundBridge: + case OffRampOrderStatus.refunded: + case OffRampOrderStatus.depositError: + case OffRampOrderStatus.depositTxConfirmError: + case OffRampOrderStatus.depositTxRequired: + case OffRampOrderStatus.failure: + return const Stream.empty(); + } + }) + .whereNotNull() + .listen( + (event) => (_db.update(_db.offRampOrderRows) + ..where((tbl) => tbl.id.equals(orderId))) + .write(event), + ); + } + + AsyncResult createPreOrder({ + required CryptoAmount submittedAmount, + required FiatAmount receiveAmount, + }) => + tryEitherAsync((_) async { + { + final order = OffRampOrderRow( + id: const Uuid().v4(), + partnerOrderId: '', + amount: submittedAmount.value, + token: Token.usdc.address, + receiveAmount: receiveAmount.value, + fiatSymbol: receiveAmount.fiatCurrency.symbol, + created: DateTime.now(), + humanStatus: '', + machineStatus: '', + partner: RampPartner.brij, + status: OffRampOrderStatus.waitingUserVerification, + transaction: '', + depositAddress: '', + slot: BigInt.zero, + bridgeAmount: null, + ); + + await _db.into(_db.offRampOrderRows).insert(order); + + return order.id; + } + }); + + AsyncResult createOrUpdate({ + required CryptoAmount submittedAmount, + required FiatAmount receiveAmount, + required String partnerAuthPk, + String? preOrderId, + }) => + tryEitherAsync((_) async { + { + await _kycRepository.grantPartnerAccess(partnerAuthPk); + + final user = await _kycRepository.fetchUser(); + + final validUser = user?.let( + (u) => u.accountNumber != null && u.bankCode != null ? u : null, + ); + + if (validUser == null) { + throw Exception( + 'Invalid user data: User not found or missing bank information', + ); + } + + final orderId = await _kycRepository.createOffRampOrder( + cryptoAmount: submittedAmount.value.toString(), + cryptoCurrency: submittedAmount.cryptoCurrency.token.symbol, + fiatAmount: receiveAmount.value.toString(), + fiatCurrency: receiveAmount.currency.symbol, + partnerPK: partnerAuthPk, + bankAccount: validUser.accountNumber ?? '', + bankName: validUser.bankCode ?? '', + ); + + final order = OffRampOrderRow( + id: preOrderId ?? const Uuid().v4(), + partnerOrderId: orderId, + amount: submittedAmount.value, + token: Token.usdc.address, + receiveAmount: receiveAmount.value, + fiatSymbol: receiveAmount.fiatCurrency.symbol, + created: DateTime.now(), + humanStatus: '', + machineStatus: '', + partner: RampPartner.brij, + status: OffRampOrderStatus.waitingPartnerReview, + transaction: '', + depositAddress: '', + slot: BigInt.zero, + bridgeAmount: null, + ); + + await _db.into(_db.offRampOrderRows).insertOnConflictUpdate(order); + _subscribe(order.id); + + final countryCode = await _kycRepository.fetchUser().letAsync( + (user) => user?.countryCode ?? '', + ); + + _analytics.rampInitiated( + partner: RampPartner.brij, + rampType: RampType.offRamp.name, + amount: submittedAmount.value.toString(), + countryCode: countryCode, + id: order.id, + ); + + return order.id; + } + }); + + void _waitingPartnerReviewWatcher(OffRampOrderRow order) { + final id = order.id; + + if (_watchers.containsKey(id)) { + return; + } + + _watchers[id] = Stream.periodic(const Duration(seconds: 10)) + .startWith(null) + .listen((_) async { + final statement = _db.update(_db.offRampOrderRows) + ..where( + (tbl) => tbl.id.equals(id), + ); + + final orderData = await _kycRepository.fetchOrder(order.partnerOrderId); + final kycStatus = KycOrderStatus.fromString(orderData.status); + + final status = switch (kycStatus) { + KycOrderStatus.completed => OffRampOrderStatus.completed, + KycOrderStatus.unknown || + KycOrderStatus.rejected => + OffRampOrderStatus.rejected, + KycOrderStatus.failed => OffRampOrderStatus.failure, + KycOrderStatus.pending => OffRampOrderStatus.waitingPartnerReview, + KycOrderStatus.accepted => OffRampOrderStatus.creatingDepositTx, + }; + + if (status != order.status) { + await statement.write( + OffRampOrderRowsCompanion( + status: Value.absentIfNull(status), + depositAddress: Value.absentIfNull(orderData.cryptoWalletAddress), + ), + ); + + _removeWatcher(id); + } + }); + } + + // Either complete or fail + void _waitingForPartnerWatcher(OffRampOrderRow order) { + final id = order.id; + + if (_watchers.containsKey(id)) { + return; + } + + _watchers[id] = Stream.periodic(const Duration(seconds: 10)) + .startWith(null) + .listen((_) async { + final statement = _db.update(_db.offRampOrderRows) + ..where( + (tbl) => tbl.id.equals(id), + ); + + final orderData = await _kycRepository.fetchOrder(order.partnerOrderId); + final kycStatus = KycOrderStatus.fromString(orderData.status); + + final status = switch (kycStatus) { + KycOrderStatus.completed => OffRampOrderStatus.completed, + KycOrderStatus.unknown || + KycOrderStatus.rejected => + OffRampOrderStatus.rejected, + KycOrderStatus.failed => OffRampOrderStatus.failure, + KycOrderStatus.pending => OffRampOrderStatus.waitingPartnerReview, + KycOrderStatus.accepted => OffRampOrderStatus.creatingDepositTx, + }; + + if (status == OffRampOrderStatus.creatingDepositTx) return; + + if (status != order.status) { + await statement.write( + OffRampOrderRowsCompanion( + status: Value.absentIfNull(status), + ), + ); + + _removeWatcher(id); + } + }); + } + + Future _createTx({ + required CryptoAmount amount, + required Ed25519HDPublicKey receiver, + }) async { + final dto = CreateDirectPaymentRequestDto( + senderAccount: _account.address, + receiverAccount: receiver.toBase58(), + amount: amount.value, + referenceAccount: null, + cluster: apiCluster, + ); + final response = await _ecClient.createDirectPayment(dto); + + final tx = await SignedTx.decode(response.transaction) + .let((it) => it.resign(_account)); + + return OffRampOrderRowsCompanion( + status: const Value(OffRampOrderStatus.depositTxReady), + transaction: Value(tx.encode()), + slot: Value(response.slot), + ); + } + + Future _sendTx((SignedTx, BigInt) tx) async { + final sent = await _sender.send(tx.$1, minContextSlot: tx.$2); + + switch (sent) { + case TxSendSent(): + break; + case TxSendInvalidBlockhash(): + return OffRampOrderRowsCompanion( + status: const Value(OffRampOrderStatus.depositError), + transaction: const Value(''), + slot: Value(BigInt.zero), + ); + case TxSendFailure(:final reason): + return OffRampOrderRowsCompanion( + status: reason == TxFailureReason.insufficientFunds + ? const Value(OffRampOrderStatus.insufficientFunds) + : const Value(OffRampOrderStatus.depositError), + transaction: const Value(''), + slot: Value(BigInt.zero), + ); + case TxSendNetworkError(): + return _depositError; + } + + final confirmed = await _sender.wait( + tx.$1, + minContextSlot: tx.$2, + txType: 'OffRamp', + ); + + switch (confirmed) { + case TxWaitSuccess(): + return const OffRampOrderRowsCompanion( + status: Value(OffRampOrderStatus.waitingForPartner), + ); + case TxWaitFailure(:final reason): + return OffRampOrderRowsCompanion( + status: reason == TxFailureReason.insufficientFunds + ? const Value(OffRampOrderStatus.insufficientFunds) + : const Value(OffRampOrderStatus.depositTxConfirmError), + transaction: const Value(''), + slot: Value(BigInt.zero), + ); + case TxWaitNetworkError(): + return _depositError; + } + } + + void _removeWatcher(String id) { + _watchers.remove(id)?.cancel(); + } + + @override + Future onDispose() async { + await Future.wait(_subscriptions.values.map((it) => it.cancel())); + _watchers.values.map((it) => it.cancel()); + } + + final _depositError = const OffRampOrderRowsCompanion( + status: Value(OffRampOrderStatus.depositTxConfirmError), + ); +} diff --git a/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_on_ramp_order_service.dart b/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_on_ramp_order_service.dart new file mode 100644 index 0000000000..652a555c56 --- /dev/null +++ b/packages/espressocash_app/lib/features/ramp/partners/kyc/services/brij_on_ramp_order_service.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:dfunc/dfunc.dart'; +import 'package:drift/drift.dart'; +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../../data/db/db.dart'; +import '../../../../accounts/auth_scope.dart'; +import '../../../../analytics/analytics_manager.dart'; +import '../../../../currency/models/amount.dart'; +import '../../../../kyc_sharing/data/kyc_repository.dart'; +import '../../../../kyc_sharing/models/kyc_order_status.dart'; +import '../../../../kyc_sharing/services/kyc_service.dart'; +import '../../../../kyc_sharing/utils/kyc_utils.dart'; +import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; +import '../../../../tokens/token.dart'; + +@Singleton(scope: authScope) +class BrijOnRampOrderService implements Disposable { + BrijOnRampOrderService( + this._db, + this._kycRepository, + this._kycSharingService, + this._analytics, + ); + + final MyDatabase _db; + final KycRepository _kycRepository; + final KycSharingService _kycSharingService; + final AnalyticsManager _analytics; + + final Map> _subscriptions = {}; + final Map> _watchers = {}; + + @PostConstruct(preResolve: true) + Future init() async { + final query = _db.select(_db.onRampOrderRows) + ..where( + (tbl) => + tbl.status.isNotInValues([ + OnRampOrderStatus.completed, + OnRampOrderStatus.failure, + ]) & + tbl.partner.equalsValue(RampPartner.brij), + ); + + final orders = await query.get(); + + for (final order in orders) { + if (order.partner != RampPartner.brij) { + continue; + } + + _subscribe(order.id); + } + } + + void _subscribe(String orderId) { + _subscriptions[orderId] = (_db.select(_db.onRampOrderRows) + ..where((tbl) => tbl.id.equals(orderId))) + .watchSingleOrNull() + .whereNotNull() + .asyncExpand((order) { + switch (order.status) { + case OnRampOrderStatus.waitingPartnerReview: + _waitingPartnerReviewWatcher(order); + + return const Stream.empty(); + + case OnRampOrderStatus.waitingForPartner: + _waitingForPartnerWatcher(order); + + return const Stream.empty(); + + case OnRampOrderStatus.waitingUserVerification: + case OnRampOrderStatus.pending: + case OnRampOrderStatus.preProcessing: + case OnRampOrderStatus.postProcessing: + case OnRampOrderStatus.waitingForBridge: + case OnRampOrderStatus.completed: + case OnRampOrderStatus.failure: + case OnRampOrderStatus.waitingForDeposit: + case OnRampOrderStatus.depositExpired: + case OnRampOrderStatus.rejected: + return const Stream.empty(); + } + }) + .whereNotNull() + .listen( + (event) => (_db.update(_db.onRampOrderRows) + ..where((tbl) => tbl.id.equals(orderId))) + .write(event), + ); + } + + AsyncResult createPreOrder({ + required FiatAmount submittedAmount, + required CryptoAmount receiveAmount, + }) => + tryEitherAsync((_) async { + { + final order = OnRampOrderRow( + id: const Uuid().v4(), + amount: submittedAmount.value, + bankTransferAmount: submittedAmount.value, + receiveAmount: receiveAmount.value, + fiatSymbol: submittedAmount.currency.symbol, + partnerOrderId: '', + token: Token.usdc.address, + humanStatus: '', + machineStatus: '', + isCompleted: false, + created: DateTime.now(), + txHash: '', + partner: RampPartner.brij, + status: OnRampOrderStatus.waitingUserVerification, + bankAccount: null, + bankName: null, + authToken: null, + moreInfoUrl: null, + ); + + await _db.into(_db.onRampOrderRows).insert(order); + + return order.id; + } + }); + + AsyncResult createOrUpdate({ + required FiatAmount submittedAmount, + required CryptoAmount receiveAmount, + required String partnerAuthPk, + String? preOrderId, + }) => + tryEitherAsync((_) async { + { + await _kycRepository.grantPartnerAccess(partnerAuthPk); + + final orderId = await _kycRepository.createOnRampOrder( + cryptoAmount: receiveAmount.value.toString(), + cryptoCurrency: receiveAmount.cryptoCurrency.token.symbol, + fiatAmount: submittedAmount.value.toString(), + fiatCurrency: submittedAmount.currency.symbol, + partnerPK: partnerAuthPk, + ); + + final order = OnRampOrderRow( + id: preOrderId ?? const Uuid().v4(), + partnerOrderId: orderId, + amount: submittedAmount.value, + token: Token.usdc.address, + humanStatus: '', + machineStatus: '', + isCompleted: false, + created: DateTime.now(), + txHash: '', + partner: RampPartner.brij, + receiveAmount: receiveAmount.value, + status: OnRampOrderStatus.waitingPartnerReview, + bankAccount: null, + bankName: null, + bankTransferAmount: submittedAmount.value, + fiatSymbol: submittedAmount.currency.symbol, + authToken: null, + moreInfoUrl: null, + ); + + await _db.into(_db.onRampOrderRows).insertOnConflictUpdate(order); + _subscribe(order.id); + + final countryCode = _kycSharingService.value?.countryCode; + + _analytics.rampInitiated( + partner: RampPartner.brij, + rampType: RampType.onRamp.name, + amount: submittedAmount.value.toString(), + countryCode: countryCode ?? '', + id: order.id, + ); + + return order.id; + } + }); + + // Either approve or reject + void _waitingPartnerReviewWatcher(OnRampOrderRow order) { + final id = order.id; + + if (_watchers.containsKey(id)) { + return; + } + + _watchers[id] = Stream.periodic(const Duration(seconds: 10)) + .startWith(null) + .listen((_) async { + final statement = _db.update(_db.onRampOrderRows) + ..where( + (tbl) => tbl.id.equals(id), + ); + + final orderData = await _kycRepository.fetchOrder(order.partnerOrderId); + final kycStatus = KycOrderStatus.fromString(orderData.status); + + final status = switch (kycStatus) { + KycOrderStatus.completed => OnRampOrderStatus.completed, + KycOrderStatus.unknown || + KycOrderStatus.rejected => + OnRampOrderStatus.rejected, + KycOrderStatus.failed => OnRampOrderStatus.failure, + KycOrderStatus.pending => OnRampOrderStatus.waitingPartnerReview, + KycOrderStatus.accepted => OnRampOrderStatus.waitingForDeposit, + }; + + if (status != order.status) { + await statement.write( + OnRampOrderRowsCompanion( + status: Value.absentIfNull(status), + bankAccount: Value.absentIfNull(orderData.bankAccount), + bankName: Value.absentIfNull(orderData.bankName), + bankTransferExpiry: Value.absentIfNull( + DateTime.now().add(const Duration(minutes: 30)), + ), + ), + ); + + _removeWatcher(id); + } + }); + } + + // Either complete or fail + void _waitingForPartnerWatcher(OnRampOrderRow order) { + final id = order.id; + + if (_watchers.containsKey(id)) { + return; + } + + _watchers[id] = Stream.periodic(const Duration(seconds: 10)) + .startWith(null) + .listen((_) async { + final statement = _db.update(_db.onRampOrderRows) + ..where( + (tbl) => tbl.id.equals(id), + ); + + final orderData = await _kycRepository.fetchOrder(order.partnerOrderId); + final kycStatus = KycOrderStatus.fromString(orderData.status); + + final status = switch (kycStatus) { + KycOrderStatus.completed => OnRampOrderStatus.completed, + KycOrderStatus.unknown || + KycOrderStatus.rejected => + OnRampOrderStatus.rejected, + KycOrderStatus.failed => OnRampOrderStatus.failure, + KycOrderStatus.pending => OnRampOrderStatus.waitingPartnerReview, + KycOrderStatus.accepted => OnRampOrderStatus.waitingForDeposit, + }; + + if (status == OnRampOrderStatus.waitingForDeposit) return; + + if (status != order.status) { + await statement.write( + OnRampOrderRowsCompanion( + status: Value.absentIfNull(status), + txHash: Value.absentIfNull(orderData.transactionId), + ), + ); + + _removeWatcher(id); + } + }); + } + + void _removeWatcher(String id) { + _watchers.remove(id)?.cancel(); + } + + @override + Future onDispose() async { + await Future.wait(_subscriptions.values.map((it) => it.cancel())); + _watchers.values.map((it) => it.cancel()); + } +} diff --git a/packages/espressocash_app/lib/features/ramp/partners/kyc/widgets/launch.dart b/packages/espressocash_app/lib/features/ramp/partners/kyc/widgets/launch.dart new file mode 100644 index 0000000000..4ea79e46db --- /dev/null +++ b/packages/espressocash_app/lib/features/ramp/partners/kyc/widgets/launch.dart @@ -0,0 +1,475 @@ +import 'package:decimal/decimal.dart'; +import 'package:dfunc/dfunc.dart'; +import 'package:espressocash_api/espressocash_api.dart'; +import 'package:flutter/material.dart'; +import 'package:kyc_client_dart/kyc_client_dart.dart'; + +import '../../../../../di.dart'; +import '../../../../../l10n/l10n.dart'; +import '../../../../../ui/loader.dart'; +import '../../../../../ui/snackbar.dart'; +import '../../../../currency/models/amount.dart'; +import '../../../../currency/models/currency.dart'; +import '../../../../kyc_sharing/services/kyc_service.dart'; +import '../../../../kyc_sharing/utils/kyc_utils.dart'; +import '../../../../kyc_sharing/widgets/kyc_flow.dart'; +import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; +import '../../../screens/off_ramp_order_screen.dart'; +import '../../../screens/on_ramp_order_screen.dart'; +import '../../../screens/ramp_amount_screen.dart'; +import '../../scalex/data/scalex_repository.dart'; +import '../services/brij_off_ramp_order_service.dart'; +import '../services/brij_on_ramp_order_service.dart'; + +typedef PreOrderData = ({String? preOrderId, Amount? preAmount}); + +extension BuildContextExt on BuildContext { + Future launchKycOnRamp({PreOrderData? preOrder}) async { + final kycStatus = sl().value?.kycStatus; + + if (preOrder?.preOrderId == null && + kycStatus != ValidationStatus.approved) { + final data = await _createOnRampPreOrder(); + + preOrder = data; + + if (data?.preAmount == null) return; + } + + final kycPassed = await openKycFlow(rampType: RampType.onRamp); + + if (!kycPassed) return; + + final rateAndFee = await _fetchRateAndFee(); + + if (rateAndFee == null) { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + + return; + } + + Amount? amount; + + final double rampRate = rateAndFee.onRampRate ?? 0; + final double rampFeePercentage = rateAndFee.onRampFeePercentage ?? 0; + final double fixedFee = rateAndFee.fixedOnRampFee ?? 0; + + const partner = RampPartner.brij; + + final minAmountNGN = + partner.minimumAmountInDecimal * Decimal.parse(rampRate.toString()); + + await RampAmountScreen.push( + this, + partner: partner, + initialAmount: preOrder?.preAmount, + onSubmitted: (Amount? value) { + Navigator.pop(this); + amount = value; + }, + minAmount: minAmountNGN, + currency: Currency.ngn, + receiveCurrency: Currency.usdc, + calculateEquivalent: (Amount amount) async => Either.right( + amount.calculateOnRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ), + ), + calculateFee: (amount) async { + final fee = amount.calculateOnRampFee( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + return Either.right( + ( + ourFee: null, + partnerFee: '${rampFeePercentage * 100}% + \$$fixedFee', + totalFee: fee, + extraFee: null, + ), + ); + }, + exchangeRate: '1 USDC = $rampRate NGN', + type: RampType.onRamp, + ); + + final submittedAmount = amount; + + if (submittedAmount == null) return; + + final equivalentAmount = submittedAmount.calculateOnRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + final orderId = await runWithLoader( + this, + () => sl() + .createOrUpdate( + preOrderId: preOrder?.preOrderId, + receiveAmount: equivalentAmount, + submittedAmount: submittedAmount as FiatAmount, + partnerAuthPk: partner.partnerPK ?? '', + ) + .then( + (order) => order.fold( + (error) => null, + (id) => id, + ), + ), + ); + + if (orderId != null) { + OnRampOrderScreen.push(this, id: orderId); + } else { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + } + } + + Future launchKycOffRamp({PreOrderData? preOrder}) async { + final kycStatus = sl().value?.kycStatus; + + if (preOrder?.preOrderId == null && + kycStatus != ValidationStatus.approved) { + final data = await _createOffRampPreOrder(); + + preOrder = data; + + if (data?.preAmount == null) return; + } + + final kycPassed = await openKycFlow(rampType: RampType.offRamp); + + if (!kycPassed) return; + + final rateAndFee = await _fetchRateAndFee(); + + if (rateAndFee == null) { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + + return; + } + + Amount? amount; + + final double rampRate = rateAndFee.offRampRate; + final double rampFeePercentage = rateAndFee.offRampFeePercentage; + final double fixedFee = rateAndFee.fixedOffRampFee; + + const partner = RampPartner.brij; + + await RampAmountScreen.push( + this, + partner: partner, + initialAmount: preOrder?.preAmount, + onSubmitted: (value) { + Navigator.pop(this); + amount = value; + }, + minAmount: partner.minimumAmountInDecimal, + currency: Currency.usdc, + receiveCurrency: Currency.ngn, + calculateEquivalent: (amount) async => Either.right( + amount.calculateOffRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ), + ), + exchangeRate: '1 USDC = $rampRate NGN', + calculateFee: (amount) async { + final fee = amount.calculateOffRampFee( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + return Either.right( + ( + ourFee: null, + partnerFee: '${rampFeePercentage * 100}% + \$$fixedFee', + totalFee: fee, + extraFee: null, + ), + ); + }, + type: RampType.offRamp, + ); + + final submittedAmount = amount; + + if (submittedAmount is! CryptoAmount) return; + + final equivalentAmount = submittedAmount.calculateOffRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + final orderId = await runWithLoader( + this, + () => sl() + .createOrUpdate( + preOrderId: preOrder?.preOrderId, + receiveAmount: equivalentAmount, + submittedAmount: submittedAmount, + partnerAuthPk: partner.partnerPK ?? '', + ) + .then( + (order) => order.fold( + (error) => null, + (id) => id, + ), + ), + ); + + if (orderId != null) { + OffRampOrderScreen.push(this, id: orderId); + } else { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + } + } + + Future _createOnRampPreOrder() async { + final rateAndFee = await _fetchRateAndFee(); + + if (rateAndFee == null) { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + + return null; + } + + final double rampRate = rateAndFee.onRampRate ?? 0; + final double rampFeePercentage = rateAndFee.onRampFeePercentage ?? 0; + final double fixedFee = rateAndFee.fixedOnRampFee ?? 0; + + const partner = RampPartner.brij; + + final minAmountNGN = + partner.minimumAmountInDecimal * Decimal.parse(rampRate.toString()); + + Amount? preAmount; + String? preOrderId; + + await RampAmountScreen.push( + this, + partner: partner, + onSubmitted: (Amount? value) async { + try { + preAmount = value; + + final submittedPreAmount = preAmount; + if (submittedPreAmount == null) return; + + final preEquivalentAmount = + submittedPreAmount.calculateOnRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + preOrderId = await runWithLoader( + this, + () => sl() + .createPreOrder( + submittedAmount: submittedPreAmount as FiatAmount, + receiveAmount: preEquivalentAmount, + ) + .then( + (order) => order.fold( + (error) => null, + (id) => id, + ), + ), + ); + } finally { + Navigator.pop(this); + } + }, + minAmount: minAmountNGN, + currency: Currency.ngn, + receiveCurrency: Currency.usdc, + type: RampType.onRamp, + ); + + return (preOrderId: preOrderId, preAmount: preAmount); + } + + Future _createOffRampPreOrder() async { + final rateAndFee = await _fetchRateAndFee(); + + if (rateAndFee == null) { + showCpErrorSnackbar(this, message: l10n.tryAgainLater); + + return null; + } + + final double rampRate = rateAndFee.offRampRate; + final double rampFeePercentage = rateAndFee.offRampFeePercentage; + final double fixedFee = rateAndFee.fixedOffRampFee; + + const partner = RampPartner.brij; + + Amount? preAmount; + String? preOrderId; + + await RampAmountScreen.push( + this, + partner: partner, + onSubmitted: (Amount? value) async { + try { + preAmount = value; + + final submittedPreAmount = preAmount; + if (submittedPreAmount == null) return; + + final equivalentAmount = + submittedPreAmount.calculateOffRampReceiveAmount( + exchangeRate: rampRate, + percentageFee: rampFeePercentage, + fixedFee: fixedFee, + ); + + preOrderId = await runWithLoader( + this, + () => sl() + .createPreOrder( + receiveAmount: equivalentAmount, + submittedAmount: submittedPreAmount as CryptoAmount, + ) + .then( + (order) => order.fold( + (error) => null, + (id) => id, + ), + ), + ); + } finally { + Navigator.pop(this); + } + }, + minAmount: partner.minimumAmountInDecimal, + currency: Currency.usdc, + receiveCurrency: Currency.ngn, + type: RampType.offRamp, + ); + + return (preOrderId: preOrderId, preAmount: preAmount); + } + + Future _fetchRateAndFee() => + runWithLoader(this, () async { + try { + final client = sl(); + + return await client.fetchRateAndFee(); + } on Exception { + return null; + } + }); +} + +extension on Amount { + CryptoAmount calculateOnRampReceiveAmount({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final (amountInUSDC, feeInUSDC) = _calculateOnRampAmounts( + exchangeRate: exchangeRate, + percentageFee: percentageFee, + fixedFee: fixedFee, + ); + final double netAmountInUSDC = amountInUSDC - feeInUSDC; + + return CryptoAmount( + value: + Currency.usdc.decimalToInt(Decimal.parse(netAmountInUSDC.toString())), + cryptoCurrency: Currency.usdc, + ); + } + + FiatAmount calculateOffRampReceiveAmount({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final (amountInNGN, feeInUSDC) = _calculateOffRampAmounts( + exchangeRate: exchangeRate, + percentageFee: percentageFee, + fixedFee: fixedFee, + ); + final double netAmountInNGN = amountInNGN - (feeInUSDC * exchangeRate); + + return FiatAmount( + value: + Currency.ngn.decimalToInt(Decimal.parse(netAmountInNGN.toString())), + fiatCurrency: Currency.ngn, + ); + } + + CryptoAmount calculateOnRampFee({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final (_, feeInUSDC) = _calculateOnRampAmounts( + exchangeRate: exchangeRate, + percentageFee: percentageFee, + fixedFee: fixedFee, + ); + + return CryptoAmount( + value: Currency.usdc.decimalToInt(Decimal.parse(feeInUSDC.toString())), + cryptoCurrency: Currency.usdc, + ); + } + + (double, double) _calculateOnRampAmounts({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final double inputAmountInNGN = decimal.toDouble(); + final double amountInUSDC = inputAmountInNGN / exchangeRate; + final double feeInUSDC = (amountInUSDC * percentageFee) + fixedFee; + + return (amountInUSDC, feeInUSDC); + } + + FiatAmount calculateOffRampFee({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final (_, feeInUSDC) = _calculateOffRampAmounts( + exchangeRate: exchangeRate, + percentageFee: percentageFee, + fixedFee: fixedFee, + ); + final double feeInNGN = feeInUSDC * exchangeRate; + + return FiatAmount( + value: Currency.ngn.decimalToInt(Decimal.parse(feeInNGN.toString())), + fiatCurrency: Currency.ngn, + ); + } + + (double, double) _calculateOffRampAmounts({ + required double exchangeRate, + required double percentageFee, + required double fixedFee, + }) { + final double inputAmountInUSDC = decimal.toDouble(); + final double feeInUSDC = (inputAmountInUSDC * percentageFee) + fixedFee; + final double amountInNGN = inputAmountInUSDC * exchangeRate; + + return (amountInNGN, feeInUSDC); + } +} diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_client.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_client.dart index 2dc3f74878..fe73a57fca 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_client.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_client.dart @@ -2,8 +2,8 @@ import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import 'package:retrofit/retrofit.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../stellar/constants.dart'; -import '../../../models/ramp_type.dart'; import 'dto.dart'; import 'moneygram_interceptor.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_interceptor.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_interceptor.dart index 20695c1125..fc6260ac40 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_interceptor.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/data/moneygram_interceptor.dart @@ -5,8 +5,8 @@ import 'package:injectable/injectable.dart'; import '../../../../../data/db/db.dart'; import '../../../../accounts/auth_scope.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../stellar/service/stellar_client.dart'; -import '../../../models/ramp_type.dart'; @LazySingleton(scope: authScope) class MoneygramInterceptor extends Interceptor { diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_fees_service.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_fees_service.dart index 42e6a17e07..9110b0e53a 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_fees_service.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_fees_service.dart @@ -6,7 +6,7 @@ import 'package:injectable/injectable.dart'; import '../../../../accounts/auth_scope.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; -import '../../../models/ramp_type.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; typedef MoneygramFees = ({ Amount receiveAmount, diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_off_ramp_service.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_off_ramp_service.dart index b37a5093b1..be9de961de 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_off_ramp_service.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_off_ramp_service.dart @@ -21,6 +21,7 @@ import '../../../../balances/services/refresh_balance.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../stellar/models/stellar_wallet.dart'; import '../../../../stellar/service/stellar_client.dart'; import '../../../../tokens/token.dart'; @@ -29,7 +30,6 @@ import '../../../../transactions/services/resign_tx.dart'; import '../../../../transactions/services/tx_confirm.dart'; import '../../../../transactions/services/tx_sender.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../services/off_ramp_order_service.dart'; import '../data/allbridge_client.dart'; import '../data/allbridge_dto.dart' hide TransactionStatus; @@ -159,6 +159,9 @@ class MoneygramOffRampOrderService implements Disposable { case OffRampOrderStatus.insufficientFunds: case OffRampOrderStatus.depositTxRequired: case OffRampOrderStatus.failure: + case OffRampOrderStatus.waitingUserVerification: + case OffRampOrderStatus.waitingPartnerReview: + case OffRampOrderStatus.rejected: return const Stream.empty(); } }) diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_on_ramp_service.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_on_ramp_service.dart index 6b8e16b19b..07e115daff 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_on_ramp_service.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/service/moneygram_on_ramp_service.dart @@ -19,12 +19,12 @@ import '../../../../balances/services/refresh_balance.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../stellar/models/stellar_wallet.dart'; import '../../../../stellar/service/stellar_client.dart'; import '../../../../tokens/token.dart'; import '../../../../transactions/models/tx_results.dart'; import '../../../../transactions/services/tx_confirm.dart'; -import '../../../models/ramp_type.dart'; import '../data/allbridge_client.dart'; import '../data/allbridge_dto.dart' hide TransactionStatus; import '../data/dto.dart'; @@ -125,7 +125,10 @@ class MoneygramOnRampOrderService implements Disposable { return const Stream.empty(); case OnRampOrderStatus.waitingForDeposit: + case OnRampOrderStatus.waitingUserVerification: + case OnRampOrderStatus.waitingPartnerReview: case OnRampOrderStatus.depositExpired: + case OnRampOrderStatus.rejected: return const Stream.empty(); } }) diff --git a/packages/espressocash_app/lib/features/ramp/partners/moneygram/widgets/launch.dart b/packages/espressocash_app/lib/features/ramp/partners/moneygram/widgets/launch.dart index 1a1cb8ebfa..8bc3b7abf9 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/moneygram/widgets/launch.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/moneygram/widgets/launch.dart @@ -17,10 +17,10 @@ import '../../../../../utils/errors.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../../stellar/models/stellar_wallet.dart'; import '../../../../stellar/service/stellar_client.dart'; import '../../../models/profile_data.dart'; -import '../../../models/ramp_type.dart'; import '../../../screens/off_ramp_order_screen.dart'; import '../../../screens/on_ramp_order_screen.dart'; import '../../../screens/ramp_amount_screen.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_off_ramp_order_watcher.dart b/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_off_ramp_order_watcher.dart index a8694fadd5..7d6ee3e3e3 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_off_ramp_order_watcher.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_off_ramp_order_watcher.dart @@ -8,8 +8,8 @@ import 'package:rxdart/rxdart.dart'; import '../../../../../../data/db/db.dart'; import '../../../../analytics/analytics_manager.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../models/ramp_watcher.dart'; import '../data/scalex_repository.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_on_ramp_order_watcher.dart b/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_on_ramp_order_watcher.dart index db25523a91..3cae28a4fe 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_on_ramp_order_watcher.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/scalex/services/scalex_on_ramp_order_watcher.dart @@ -8,8 +8,8 @@ import 'package:rxdart/rxdart.dart'; import '../../../../../../data/db/db.dart'; import '../../../../analytics/analytics_manager.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../data/my_database_ext.dart'; -import '../../../models/ramp_type.dart'; import '../../../models/ramp_watcher.dart'; import '../data/scalex_repository.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/partners/scalex/widgets/launch.dart b/packages/espressocash_app/lib/features/ramp/partners/scalex/widgets/launch.dart index 196b110493..9e9f149e0a 100644 --- a/packages/espressocash_app/lib/features/ramp/partners/scalex/widgets/launch.dart +++ b/packages/espressocash_app/lib/features/ramp/partners/scalex/widgets/launch.dart @@ -15,8 +15,8 @@ import '../../../../../ui/web_view_screen.dart'; import '../../../../currency/models/amount.dart'; import '../../../../currency/models/currency.dart'; import '../../../../ramp_partner/models/ramp_partner.dart'; +import '../../../../ramp_partner/models/ramp_type.dart'; import '../../../models/profile_data.dart'; -import '../../../models/ramp_type.dart'; import '../../../screens/off_ramp_order_screen.dart'; import '../../../screens/on_ramp_order_screen.dart'; import '../../../screens/ramp_amount_screen.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/screens/off_ramp_order_screen.dart b/packages/espressocash_app/lib/features/ramp/screens/off_ramp_order_screen.dart index 634e4c266d..261f61772d 100644 --- a/packages/espressocash_app/lib/features/ramp/screens/off_ramp_order_screen.dart +++ b/packages/espressocash_app/lib/features/ramp/screens/off_ramp_order_screen.dart @@ -134,7 +134,9 @@ class OffRampOrderScreenContent extends StatelessWidget { OffRampOrderStatus.depositTxConfirmError || OffRampOrderStatus.depositError => context.l10n.offRampDepositError, - OffRampOrderStatus.failure => context.l10n.offRampWithdrawalFailure, + OffRampOrderStatus.failure || + OffRampOrderStatus.rejected => + context.l10n.offRampWithdrawalFailure, OffRampOrderStatus.completed => context.l10n.offRampWithdrawSuccess, OffRampOrderStatus.cancelled => context.l10n.offRampWithdrawCancelled( totalAmount.format(locale), @@ -149,6 +151,9 @@ class OffRampOrderScreenContent extends StatelessWidget { OffRampOrderStatus.waitingForRefundBridge => context.l10n.refundInProgressText, OffRampOrderStatus.refunded => context.l10n.refundSuccessText, + OffRampOrderStatus.waitingUserVerification => + 'Waiting for user verification', + OffRampOrderStatus.waitingPartnerReview => 'Waiting for partner review', }; final Widget? primaryButton = switch (order.status) { @@ -158,7 +163,9 @@ class OffRampOrderScreenContent extends StatelessWidget { order.partner != RampPartner.moneygram ? _RetryButton(handleRetry: handleRetry) : null, - OffRampOrderStatus.failure => const _ContactUsButton(), + OffRampOrderStatus.failure || + OffRampOrderStatus.rejected => + const _ContactUsButton(), OffRampOrderStatus.ready => _ContinueButton(handleContinue: handleContinue), OffRampOrderStatus.waitingForPartner => isMoneygramOrder @@ -174,7 +181,9 @@ class OffRampOrderScreenContent extends StatelessWidget { OffRampOrderStatus.waitingForRefundBridge || OffRampOrderStatus.completed || OffRampOrderStatus.refunded || - OffRampOrderStatus.cancelled => + OffRampOrderStatus.cancelled || + OffRampOrderStatus.waitingPartnerReview || + OffRampOrderStatus.waitingUserVerification => null, }; @@ -186,6 +195,7 @@ class OffRampOrderScreenContent extends StatelessWidget { order.status == OffRampOrderStatus.insufficientFunds; final showCancelButton = order.status == OffRampOrderStatus.depositError || + order.status == OffRampOrderStatus.insufficientFunds || order.status == OffRampOrderStatus.ready || showMoneygramCancel; @@ -489,11 +499,14 @@ extension on OffRampOrderStatus { OffRampOrderStatus.sendingDepositTx || OffRampOrderStatus.processingRefund || OffRampOrderStatus.waitingForRefundBridge || + OffRampOrderStatus.waitingUserVerification || + OffRampOrderStatus.waitingPartnerReview || OffRampOrderStatus.waitingForPartner => CpStatusType.info, OffRampOrderStatus.depositError || OffRampOrderStatus.depositTxConfirmError || OffRampOrderStatus.insufficientFunds || + OffRampOrderStatus.rejected || OffRampOrderStatus.failure => CpStatusType.error, OffRampOrderStatus.completed => CpStatusType.success, @@ -512,11 +525,14 @@ extension on OffRampOrderStatus { OffRampOrderStatus.ready || OffRampOrderStatus.processingRefund || OffRampOrderStatus.waitingForRefundBridge || + OffRampOrderStatus.waitingUserVerification || + OffRampOrderStatus.waitingPartnerReview || OffRampOrderStatus.waitingForPartner => CpTimelineStatus.inProgress, OffRampOrderStatus.depositTxConfirmError || OffRampOrderStatus.depositError || OffRampOrderStatus.insufficientFunds || + OffRampOrderStatus.rejected || OffRampOrderStatus.failure => CpTimelineStatus.failure, OffRampOrderStatus.completed => CpTimelineStatus.success, @@ -526,6 +542,8 @@ extension on OffRampOrderStatus { }; int toActiveItem() => switch (this) { + OffRampOrderStatus.waitingUserVerification || + OffRampOrderStatus.waitingPartnerReview || OffRampOrderStatus.preProcessing || OffRampOrderStatus.postProcessing || OffRampOrderStatus.ready || @@ -538,6 +556,7 @@ extension on OffRampOrderStatus { OffRampOrderStatus.insufficientFunds || OffRampOrderStatus.processingRefund || OffRampOrderStatus.waitingForRefundBridge || + OffRampOrderStatus.rejected || OffRampOrderStatus.cancelled => 1, OffRampOrderStatus.waitingForPartner || @@ -548,6 +567,9 @@ extension on OffRampOrderStatus { }; int toActiveItemForMoneygram() => switch (this) { + OffRampOrderStatus.waitingPartnerReview || + OffRampOrderStatus.waitingUserVerification || + OffRampOrderStatus.rejected || OffRampOrderStatus.preProcessing || OffRampOrderStatus.postProcessing || OffRampOrderStatus.depositError || @@ -596,11 +618,15 @@ extension on OffRampOrderStatus { OffRampOrderStatus.insufficientFunds || OffRampOrderStatus.failure || OffRampOrderStatus.cancelled || - OffRampOrderStatus.refunded => + OffRampOrderStatus.refunded || + OffRampOrderStatus.waitingUserVerification || + OffRampOrderStatus.waitingPartnerReview || + OffRampOrderStatus.rejected => false, }; String toMoneygramStatus(BuildContext context) => switch (this) { + OffRampOrderStatus.waitingPartnerReview || OffRampOrderStatus.preProcessing || OffRampOrderStatus.postProcessing || OffRampOrderStatus.depositTxRequired || @@ -610,11 +636,13 @@ extension on OffRampOrderStatus { OffRampOrderStatus.ready || OffRampOrderStatus.processingRefund || OffRampOrderStatus.waitingForRefundBridge || + OffRampOrderStatus.waitingUserVerification || OffRampOrderStatus.waitingForPartner => context.l10n.pending, OffRampOrderStatus.depositError || OffRampOrderStatus.depositTxConfirmError || OffRampOrderStatus.insufficientFunds || + OffRampOrderStatus.rejected || OffRampOrderStatus.failure => context.l10n.failed, OffRampOrderStatus.completed => context.l10n.completed, diff --git a/packages/espressocash_app/lib/features/ramp/screens/on_ramp_order_screen.dart b/packages/espressocash_app/lib/features/ramp/screens/on_ramp_order_screen.dart index 3703edb464..40810f4a50 100644 --- a/packages/espressocash_app/lib/features/ramp/screens/on_ramp_order_screen.dart +++ b/packages/espressocash_app/lib/features/ramp/screens/on_ramp_order_screen.dart @@ -130,11 +130,17 @@ class OnRampOrderScreenContent extends StatelessWidget { context.l10n .onRampDepositOngoing(amount.format(locale, maxDecimals: 2)), OnRampOrderStatus.depositExpired => context.l10n.onRampDepositExpired, - OnRampOrderStatus.failure => context.l10n.onRampDepositFailure, + OnRampOrderStatus.failure || + OnRampOrderStatus.rejected => + context.l10n.onRampDepositFailure, OnRampOrderStatus.completed => context.l10n.onRampDepositSuccess, + OnRampOrderStatus.waitingUserVerification => + 'Waiting for user verification', + OnRampOrderStatus.waitingPartnerReview => 'Waiting for partner review', }; final String? statusSubtitle = switch (order.status) { + OnRampOrderStatus.waitingUserVerification || OnRampOrderStatus.waitingForPartner || OnRampOrderStatus.postProcessing => context.l10n.onRampAwaitingFunds, @@ -145,6 +151,8 @@ class OnRampOrderScreenContent extends StatelessWidget { OnRampOrderStatus.waitingForDeposit || OnRampOrderStatus.depositExpired || OnRampOrderStatus.failure || + OnRampOrderStatus.rejected || + OnRampOrderStatus.waitingPartnerReview || OnRampOrderStatus.completed => null }; @@ -379,11 +387,14 @@ extension on OnRampOrderStatus { OnRampOrderStatus.preProcessing || OnRampOrderStatus.postProcessing || OnRampOrderStatus.waitingForBridge || + OnRampOrderStatus.waitingUserVerification || + OnRampOrderStatus.waitingPartnerReview || OnRampOrderStatus.waitingForDeposit || OnRampOrderStatus.waitingForPartner => CpStatusType.info, OnRampOrderStatus.depositExpired || - OnRampOrderStatus.failure => + OnRampOrderStatus.failure || + OnRampOrderStatus.rejected => CpStatusType.error, OnRampOrderStatus.completed => CpStatusType.success, }; @@ -393,11 +404,14 @@ extension on OnRampOrderStatus { OnRampOrderStatus.preProcessing || OnRampOrderStatus.postProcessing || OnRampOrderStatus.waitingForBridge || + OnRampOrderStatus.waitingUserVerification || + OnRampOrderStatus.waitingPartnerReview || OnRampOrderStatus.waitingForDeposit || OnRampOrderStatus.waitingForPartner => CpTimelineStatus.inProgress, OnRampOrderStatus.depositExpired || - OnRampOrderStatus.failure => + OnRampOrderStatus.failure || + OnRampOrderStatus.rejected => CpTimelineStatus.failure, OnRampOrderStatus.completed => CpTimelineStatus.success, }; @@ -406,7 +420,10 @@ extension on OnRampOrderStatus { OnRampOrderStatus.pending || OnRampOrderStatus.preProcessing || OnRampOrderStatus.depositExpired || - OnRampOrderStatus.waitingForDeposit => + OnRampOrderStatus.waitingForDeposit || + OnRampOrderStatus.waitingUserVerification || + OnRampOrderStatus.waitingPartnerReview || + OnRampOrderStatus.rejected => 0, OnRampOrderStatus.waitingForPartner || OnRampOrderStatus.postProcessing || @@ -420,12 +437,14 @@ extension on OnRampOrderStatus { OnRampOrderStatus.pending || OnRampOrderStatus.preProcessing || OnRampOrderStatus.waitingForBridge || + OnRampOrderStatus.waitingPartnerReview || OnRampOrderStatus.waitingForDeposit || OnRampOrderStatus.postProcessing || + OnRampOrderStatus.waitingUserVerification || OnRampOrderStatus.waitingForPartner => 'Pending', OnRampOrderStatus.depositExpired => 'Expired', - OnRampOrderStatus.failure => 'Failed', + OnRampOrderStatus.rejected || OnRampOrderStatus.failure => 'Failed', OnRampOrderStatus.completed => 'Completed', }; } diff --git a/packages/espressocash_app/lib/features/ramp/screens/ramp_amount_screen.dart b/packages/espressocash_app/lib/features/ramp/screens/ramp_amount_screen.dart index 709b514687..fdbe169e5a 100644 --- a/packages/espressocash_app/lib/features/ramp/screens/ramp_amount_screen.dart +++ b/packages/espressocash_app/lib/features/ramp/screens/ramp_amount_screen.dart @@ -17,7 +17,7 @@ import '../../conversion_rates/widgets/extensions.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; import '../../ramp_partner/models/ramp_partner.dart'; -import '../models/ramp_type.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../widgets/debounce_mixin.dart'; import '../widgets/error_chip.dart'; import '../widgets/ramp_loader.dart'; @@ -44,6 +44,7 @@ class RampAmountScreen extends StatefulWidget { required this.partner, required this.exchangeRate, required this.receiveCurrency, + required this.initialAmount, this.isEstimatedRate = false, }); @@ -58,6 +59,7 @@ class RampAmountScreen extends StatefulWidget { FeeCalculator? calculateFee, String? exchangeRate, Currency? receiveCurrency, + Amount? initialAmount, bool isEstimatedRate = false, }) => Navigator.of(context).push( @@ -72,6 +74,7 @@ class RampAmountScreen extends StatefulWidget { calculateFee: calculateFee, exchangeRate: exchangeRate, receiveCurrency: receiveCurrency, + initialAmount: initialAmount, isEstimatedRate: isEstimatedRate, ), ), @@ -86,6 +89,7 @@ class RampAmountScreen extends StatefulWidget { final FeeCalculator? calculateFee; final String? exchangeRate; final Currency? receiveCurrency; + final Amount? initialAmount; final bool isEstimatedRate; @override @@ -96,6 +100,14 @@ class _RampAmountScreenState extends State { final _controller = TextEditingController(); final _minimumAmountNoticeKey = GlobalKey<_MinimumAmountNoticeState>(); + @override + void initState() { + super.initState(); + if (widget.initialAmount != null) { + _controller.text = widget.initialAmount?.decimal.toString() ?? ''; + } + } + @override void dispose() { _controller.dispose(); diff --git a/packages/espressocash_app/lib/features/ramp/screens/ramp_onboarding_screen.dart b/packages/espressocash_app/lib/features/ramp/screens/ramp_onboarding_screen.dart index a5191c3075..795555495d 100644 --- a/packages/espressocash_app/lib/features/ramp/screens/ramp_onboarding_screen.dart +++ b/packages/espressocash_app/lib/features/ramp/screens/ramp_onboarding_screen.dart @@ -10,7 +10,7 @@ import '../../../utils/email.dart'; import '../../country_picker/models/country.dart'; import '../../country_picker/widgets/country_picker.dart'; import '../../profile/data/profile_repository.dart'; -import '../models/ramp_type.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../widgets/ramp_page.dart'; class RampOnboardingScreen extends StatefulWidget { diff --git a/packages/espressocash_app/lib/features/ramp/screens/ramp_partner_select_screen.dart b/packages/espressocash_app/lib/features/ramp/screens/ramp_partner_select_screen.dart index dc666733ae..cf37b9594f 100644 --- a/packages/espressocash_app/lib/features/ramp/screens/ramp_partner_select_screen.dart +++ b/packages/espressocash_app/lib/features/ramp/screens/ramp_partner_select_screen.dart @@ -19,7 +19,7 @@ import '../../country_picker/widgets/country_picker.dart'; import '../../profile/data/profile_repository.dart'; import '../../profile/service/update_profile.dart'; import '../../ramp_partner/models/ramp_partner.dart'; -import '../models/ramp_type.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../widgets/partner_config.dart'; import '../widgets/partner_tile.dart'; diff --git a/packages/espressocash_app/lib/features/ramp/services/off_ramp_order_service.dart b/packages/espressocash_app/lib/features/ramp/services/off_ramp_order_service.dart index 6219329322..80641282bb 100644 --- a/packages/espressocash_app/lib/features/ramp/services/off_ramp_order_service.dart +++ b/packages/espressocash_app/lib/features/ramp/services/off_ramp_order_service.dart @@ -21,12 +21,12 @@ import '../../analytics/analytics_manager.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; import '../../ramp_partner/models/ramp_partner.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../../tokens/data/token_repository.dart'; import '../../tokens/token.dart'; import '../../transactions/models/tx_results.dart'; import '../../transactions/services/resign_tx.dart'; import '../../transactions/services/tx_sender.dart'; -import '../models/ramp_type.dart'; import '../models/ramp_watcher.dart'; import '../partners/coinflow/services/coinflow_off_ramp_order_watcher.dart'; import '../partners/kado/services/kado_off_ramp_order_watcher.dart'; @@ -86,12 +86,18 @@ class OffRampOrderService implements Disposable { final orders = await query.get(); for (final order in orders) { - if (order.partner == RampPartner.moneygram) { - continue; + switch (order.partner) { + case RampPartner.moneygram: + case RampPartner.brij: + continue; + case RampPartner.kado: + case RampPartner.coinflow: + case RampPartner.scalex: + case RampPartner.guardarian: + case RampPartner.rampNetwork: + _subscribe(order.id); + unawaited(_watch(order.id)); } - - _subscribe(order.id); - unawaited(_watch(order.id)); } } @@ -225,6 +231,9 @@ class OffRampOrderService implements Disposable { case OffRampOrderStatus.refunded: case OffRampOrderStatus.completed: case OffRampOrderStatus.cancelled: + case OffRampOrderStatus.waitingPartnerReview: + case OffRampOrderStatus.waitingUserVerification: + case OffRampOrderStatus.rejected: break; } } @@ -259,6 +268,9 @@ class OffRampOrderService implements Disposable { case OffRampOrderStatus.preProcessing: case OffRampOrderStatus.postProcessing: case OffRampOrderStatus.refunded: + case OffRampOrderStatus.waitingPartnerReview: + case OffRampOrderStatus.waitingUserVerification: + case OffRampOrderStatus.rejected: break; } } @@ -348,6 +360,7 @@ class OffRampOrderService implements Disposable { RampPartner.kado => sl(), RampPartner.scalex => sl(), RampPartner.coinflow => sl(), + RampPartner.brij || RampPartner.rampNetwork || RampPartner.moneygram || // moneygram orders will not reach this point RampPartner.guardarian => @@ -405,6 +418,9 @@ class OffRampOrderService implements Disposable { case OffRampOrderStatus.waitingForRefundBridge: case OffRampOrderStatus.refunded: case OffRampOrderStatus.completed: + case OffRampOrderStatus.waitingPartnerReview: + case OffRampOrderStatus.waitingUserVerification: + case OffRampOrderStatus.rejected: _subscriptions.remove(orderId)?.cancel(); _watchers[orderId]?.close(); diff --git a/packages/espressocash_app/lib/features/ramp/services/on_ramp_order_service.dart b/packages/espressocash_app/lib/features/ramp/services/on_ramp_order_service.dart index cdc1214061..1df686c779 100644 --- a/packages/espressocash_app/lib/features/ramp/services/on_ramp_order_service.dart +++ b/packages/espressocash_app/lib/features/ramp/services/on_ramp_order_service.dart @@ -14,9 +14,9 @@ import '../../analytics/analytics_manager.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; import '../../ramp_partner/models/ramp_partner.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../../tokens/data/token_repository.dart'; import '../../tokens/token.dart'; -import '../models/ramp_type.dart'; typedef OnRampOrder = ({ String id, @@ -64,11 +64,17 @@ class OnRampOrderService implements Disposable { final orders = await query.get(); for (final order in orders) { - if (order.partner == RampPartner.moneygram) { - continue; + switch (order.partner) { + case RampPartner.moneygram: + case RampPartner.brij: + continue; + case RampPartner.kado: + case RampPartner.coinflow: + case RampPartner.scalex: + case RampPartner.guardarian: + case RampPartner.rampNetwork: + _subscribe(order.id); } - - _subscribe(order.id); } } @@ -132,6 +138,7 @@ class OnRampOrderService implements Disposable { required String? bankName, required DateTime? transferExpiryDate, required FiatAmount transferAmount, + OnRampOrderStatus status = OnRampOrderStatus.waitingForDeposit, required String countryCode, }) => create( @@ -143,7 +150,7 @@ class OnRampOrderService implements Disposable { bankName: bankName, transferExpiryDate: transferExpiryDate, transferAmount: transferAmount, - status: OnRampOrderStatus.waitingForDeposit, + status: status, countryCode: countryCode, ); @@ -163,6 +170,8 @@ class OnRampOrderService implements Disposable { ), ); case OnRampOrderStatus.depositExpired: + case OnRampOrderStatus.waitingPartnerReview: + case OnRampOrderStatus.waitingUserVerification: case OnRampOrderStatus.waitingForPartner: case OnRampOrderStatus.failure: case OnRampOrderStatus.completed: @@ -170,6 +179,7 @@ class OnRampOrderService implements Disposable { case OnRampOrderStatus.preProcessing: case OnRampOrderStatus.postProcessing: case OnRampOrderStatus.waitingForBridge: + case OnRampOrderStatus.rejected: break; } } @@ -201,10 +211,8 @@ class OnRampOrderService implements Disposable { final fiatSymbol = row.fiatSymbol; final moreInfoUrl = row.moreInfoUrl; - final isManualDeposit = bankName != null && - transferExpiryDate != null && - transferAmount != null && - fiatSymbol != null; + final isManualDeposit = + bankName != null && transferAmount != null && fiatSymbol != null; final Token? token = await _tokenRepository.getToken(row.token); @@ -239,7 +247,7 @@ class OnRampOrderService implements Disposable { ? ( bankAccount: bankAccount ?? '', bankName: bankName, - transferExpiryDate: transferExpiryDate, + transferExpiryDate: transferExpiryDate ?? DateTime.now(), transferAmount: FiatAmount( value: transferAmount, fiatCurrency: currencyFromString(fiatSymbol), diff --git a/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_deposit_widget.dart b/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_deposit_widget.dart index f02ce7264d..7175f01119 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_deposit_widget.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_deposit_widget.dart @@ -11,7 +11,6 @@ import '../../../ui/colors.dart'; import '../../../ui/content_padding.dart'; import '../../../ui/dialogs.dart'; import '../../../ui/page_spacer_wrapper.dart'; -import '../../../ui/rounded_rectangle.dart'; import '../../../ui/snackbar.dart'; import '../../../ui/theme.dart'; import '../../../ui/web_view_screen.dart'; @@ -59,7 +58,7 @@ class OnRampDepositWidget extends StatelessWidget { ); return switch (partner) { - RampPartner.scalex => _ScalexDepositContent( + RampPartner.brij || RampPartner.scalex => _ScalexDepositContent( deposit: deposit, formattedTransferAmount: formattedTransferAmount, formattedReceiveAmount: formattedReceiveAmount, @@ -125,62 +124,52 @@ class _ScalexDepositContent extends StatelessWidget { const SizedBox(height: 16), _InstructionItem( index: 1, - text: context.l10n.depositInstruction1, + text: context.l10n.depositInstruction1(deposit.bankName), ), const SizedBox(height: 8), _ItemWidget( title: context.l10n.depositTransferAmount, value: formattedTransferAmount, + trailing: _CopyButton(value: formattedTransferAmount), ), - const SizedBox(height: 24), + const SizedBox(height: 4), _ItemWidget( - title: '${deposit.bankName} Account', + title: '${deposit.bankName} Account Number', value: deposit.bankAccount, + trailing: _CopyButton(value: deposit.bankAccount), + ), + const SizedBox(height: 4), + _ItemWidget( + title: 'You Receive', + value: '${formattedReceiveAmount ?? ''} ', + trailing: Padding( + padding: const EdgeInsets.only(right: 12), + child: Text( + deposit.receiveAmount?.currency.symbol.toUpperCase() ?? + '', + style: const TextStyle( + color: Colors.white, + fontSize: 34, + height: 40 / 34, + fontWeight: FontWeight.w700, + ), + ), + ), ), const SizedBox(height: 16), _InstructionItem( index: 2, text: context.l10n.depositInstruction2, ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 42), - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: context.l10n.depositReceiveAmount( - formattedReceiveAmount ?? '', - ), - ), - TextSpan( - text: - ' ${deposit.receiveAmount?.currency.symbol.toUpperCase() ?? ''}', - style: const TextStyle( - color: CpColors.yellowColor, - ), - ), - ], - ), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.23, - ), - ), - ), const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.only(left: 42), - child: Text( - context.l10n.depositDisclaimer, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w400, - ), + Text( + context.l10n.depositDisclaimer, + textAlign: TextAlign.center, + style: const TextStyle( + color: CpColors.greyColor, + fontSize: 14, + height: 18 / 14, + fontWeight: FontWeight.w400, ), ), ], @@ -192,7 +181,7 @@ class _ScalexDepositContent extends StatelessWidget { child: CpButton( width: double.infinity, onPressed: onConfirmPress, - text: context.l10n.ramp_btnContinue, + text: context.l10n.confirmTransfer, ), ), ), @@ -371,7 +360,6 @@ class _InstructionItem extends StatelessWidget { @override Widget build(BuildContext context) => Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32, @@ -394,16 +382,14 @@ class _InstructionItem extends StatelessWidget { ), const SizedBox(width: 12), Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - text, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.23, - ), + child: Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 20 / 16, + letterSpacing: 0.23, ), ), ), @@ -415,60 +401,78 @@ class _ItemWidget extends StatelessWidget { const _ItemWidget({ required this.value, required this.title, + this.trailing = const SizedBox.shrink(), }); final String title; final String value; + final Widget trailing; @override Widget build(BuildContext context) => Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.23, + Padding( + padding: const EdgeInsets.only(left: 18), + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.17, + ), ), ), - const SizedBox(height: 12), - CpRoundedRectangle( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - backgroundColor: Colors.black, + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.fromLTRB(30, 10, 18, 10), + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(40)), + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Center( - child: Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 17, - fontWeight: FontWeight.w700, - ), + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 34, + height: 40 / 34, + fontWeight: FontWeight.w700, ), ), ), - Align( - alignment: Alignment.centerRight, - child: CpButton( - text: context.l10n.copy, - minWidth: 80, - onPressed: () { - final data = ClipboardData(text: value); - Clipboard.setData(data); - showClipboardSnackbar(context); - }, - size: CpButtonSize.micro, - ), - ), + trailing, ], ), ), ], ); } + +class _CopyButton extends StatelessWidget { + const _CopyButton({required this.value}); + + final String value; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.centerRight, + child: CpButton( + text: context.l10n.copy, + minWidth: 60, + onPressed: () { + final data = ClipboardData(text: value); + Clipboard.setData(data); + showClipboardSnackbar(context); + }, + size: CpButtonSize.micro, + ), + ); +} diff --git a/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_order_details.dart b/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_order_details.dart index 10e0de961a..0abd40a538 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_order_details.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/on_ramp_order_details.dart @@ -48,7 +48,7 @@ class _OnRampOrderDetailsState extends State { _watcher = switch (onRamp.partner) { RampPartner.kado => sl(), RampPartner.scalex => sl(), - RampPartner.moneygram => null, + RampPartner.brij || RampPartner.moneygram => null, RampPartner.rampNetwork || RampPartner.coinflow || RampPartner.guardarian => diff --git a/packages/espressocash_app/lib/features/ramp/widgets/partner_config.dart b/packages/espressocash_app/lib/features/ramp/widgets/partner_config.dart index b5010e797d..d879d99e1d 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/partner_config.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/partner_config.dart @@ -20,6 +20,12 @@ IList getOnRampPartners(String? countryCode) { partners.add(RampPartner.kado); } + final isBrijEnabled = sl().isBrijEnabled(); + + if (isBrijEnabled && _brijCountries.contains(countryCode)) { + partners.add(RampPartner.brij); + } + if (_scalexCountries.contains(countryCode)) { partners.add(RampPartner.scalex); } @@ -49,6 +55,12 @@ IList getOffRampPartners(String? countryCode) { partners.add(RampPartner.coinflow); } + final isBrijEnabled = sl().isBrijEnabled(); + + if (isBrijEnabled && _brijCountries.contains(countryCode)) { + partners.add(RampPartner.brij); + } + if (_scalexCountries.contains(countryCode)) { partners.add(RampPartner.scalex); } @@ -95,3 +107,5 @@ const _moneygramOffRampCountries = { 'TL', 'TG', 'TO', 'TT', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UY', // 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'ZM', }; + +const _brijCountries = {'NG'}; diff --git a/packages/espressocash_app/lib/features/ramp/widgets/partner_tile.dart b/packages/espressocash_app/lib/features/ramp/widgets/partner_tile.dart index 1ba5bf9ebd..0922f7d864 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/partner_tile.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/partner_tile.dart @@ -5,7 +5,7 @@ import '../../../l10n/l10n.dart'; import '../../../ui/colors.dart'; import '../../ramp_partner/models/payment_methods.dart'; import '../../ramp_partner/models/ramp_partner.dart'; -import '../models/ramp_type.dart'; +import '../../ramp_partner/models/ramp_type.dart'; class PartnerTile extends StatelessWidget { const PartnerTile({ diff --git a/packages/espressocash_app/lib/features/ramp/widgets/ramp_buttons.dart b/packages/espressocash_app/lib/features/ramp/widgets/ramp_buttons.dart index 0c2f4efe03..0429c958d5 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/ramp_buttons.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/ramp_buttons.dart @@ -13,11 +13,12 @@ import '../../analytics/analytics_manager.dart'; import '../../country_picker/models/country.dart'; import '../../profile/data/profile_repository.dart'; import '../../ramp_partner/models/ramp_partner.dart'; +import '../../ramp_partner/models/ramp_type.dart'; import '../models/profile_data.dart'; -import '../models/ramp_type.dart'; import '../partners/coinflow/widgets/launch.dart'; import '../partners/guardarian/widgets/launch.dart'; import '../partners/kado/widgets/launch.dart'; +import '../partners/kyc/widgets/launch.dart'; import '../partners/moneygram/widgets/launch.dart'; import '../partners/ramp_network/widgets/launch.dart'; import '../partners/scalex/widgets/launch.dart'; @@ -210,6 +211,8 @@ extension RampBuildContextExt on BuildContext { launchGuardarianOnRamp(profile: profile, address: address); case RampPartner.scalex: launchScalexOnRamp(profile: profile, address: address); + case RampPartner.brij: + launchKycOnRamp(); case RampPartner.moneygram: launchMoneygramOnRamp(profile: profile); case RampPartner.coinflow: @@ -234,6 +237,8 @@ extension RampBuildContextExt on BuildContext { launchScalexOffRamp(profile: profile, address: address); case RampPartner.moneygram: launchMoneygramOffRamp(profile: profile); + case RampPartner.brij: + launchKycOffRamp(); case RampPartner.rampNetwork: case RampPartner.guardarian: throw UnimplementedError('Not implemented for $partner'); diff --git a/packages/espressocash_app/lib/features/ramp/widgets/ramp_page.dart b/packages/espressocash_app/lib/features/ramp/widgets/ramp_page.dart index 2c03457b8c..48c99d4831 100644 --- a/packages/espressocash_app/lib/features/ramp/widgets/ramp_page.dart +++ b/packages/espressocash_app/lib/features/ramp/widgets/ramp_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../../../gen/assets.gen.dart'; import '../../../l10n/l10n.dart'; import '../../../ui/form_page.dart'; -import '../models/ramp_type.dart'; +import '../../ramp_partner/models/ramp_type.dart'; class RampPage extends StatelessWidget { const RampPage({ diff --git a/packages/espressocash_app/lib/features/ramp_partner/models/ramp_partner.dart b/packages/espressocash_app/lib/features/ramp_partner/models/ramp_partner.dart index 03a083370c..c2ff1be2ad 100644 --- a/packages/espressocash_app/lib/features/ramp_partner/models/ramp_partner.dart +++ b/packages/espressocash_app/lib/features/ramp_partner/models/ramp_partner.dart @@ -15,6 +15,7 @@ enum RampPartner { PaymentMethod.pix, ], ), + rampNetwork( title: 'Ramp Network', minimumAmount: r'$7', @@ -26,6 +27,7 @@ enum RampPartner { PaymentMethod.bank, ], ), + coinflow( title: 'Coinflow', minimumAmount: r'$20', @@ -35,6 +37,7 @@ enum RampPartner { PaymentMethod.bank, ], ), + guardarian( title: 'Guardarian', minimumAmount: r'$5', @@ -47,26 +50,37 @@ enum RampPartner { PaymentMethod.swift, ], ), + scalex( title: 'Scalex', minimumAmount: r'$5', paymentMethods: [PaymentMethod.bank], ), + moneygram( title: 'MoneyGram', minimumAmount: r'$10', paymentMethods: [], + ), + + brij( + title: 'Espresso Network', + minimumAmount: r'$5', + paymentMethods: [], + partnerPK: '9YmsP8PoWfNaTwBjLRy8R5Yr9Ukcu2hvVvzea8mRpnKp', ); const RampPartner({ required this.title, required this.minimumAmount, required this.paymentMethods, + this.partnerPK, }); final String title; final String minimumAmount; final List paymentMethods; + final String? partnerPK; Decimal get minimumAmountInDecimal => Decimal.parse(minimumAmount.substring(1)); @@ -83,10 +97,12 @@ extension RampPartnerAssets on RampPartner { return Assets.images.coinflowIcon; case RampPartner.guardarian: return Assets.images.guardianIcon; - case RampPartner.scalex: - return Assets.images.scalexIcon; case RampPartner.moneygram: return Assets.images.moneygramIcon; + case RampPartner.scalex: + return Assets.images.scalexIcon; + case RampPartner.brij: + return Assets.images.logoIcon; } } } diff --git a/packages/espressocash_app/lib/features/ramp/models/ramp_type.dart b/packages/espressocash_app/lib/features/ramp_partner/models/ramp_type.dart similarity index 100% rename from packages/espressocash_app/lib/features/ramp/models/ramp_type.dart rename to packages/espressocash_app/lib/features/ramp_partner/models/ramp_type.dart diff --git a/packages/espressocash_app/lib/features/stellar/service/stellar_account_service.dart b/packages/espressocash_app/lib/features/stellar/service/stellar_account_service.dart index fac79ad34e..26c9e64677 100644 --- a/packages/espressocash_app/lib/features/stellar/service/stellar_account_service.dart +++ b/packages/espressocash_app/lib/features/stellar/service/stellar_account_service.dart @@ -23,7 +23,7 @@ class StellarAccountService { final address = _stellarWallet.address; Sentry.configureScope( - (scope) => scope.setExtra('stellarWalletAddress', address), + (scope) => scope.setContexts('stellarWalletAddress', address), ); _analyticsManager.setStellarAddress(address); _intercomService.updateStellarAddress(address); @@ -31,6 +31,8 @@ class StellarAccountService { @disposeMethod void dispose() { - Sentry.configureScope((scope) => scope.removeExtra('stellarWalletAddress')); + Sentry.configureScope( + (scope) => scope.removeContexts('stellarWalletAddress'), + ); } } diff --git a/packages/espressocash_app/lib/features/stellar/service/stellar_client.dart b/packages/espressocash_app/lib/features/stellar/service/stellar_client.dart index eb7c4487a6..ca98496e69 100644 --- a/packages/espressocash_app/lib/features/stellar/service/stellar_client.dart +++ b/packages/espressocash_app/lib/features/stellar/service/stellar_client.dart @@ -81,7 +81,7 @@ class StellarClient { Future getPaymentByTxId(String txId) async { final operations = await _sdk.operations.forTransaction(txId).execute(); - return operations.records?.firstOrNull; + return operations.records.firstOrNull; } Future hasUsdcTrustline({double? amount}) async { diff --git a/packages/espressocash_app/lib/l10n/intl_en.arb b/packages/espressocash_app/lib/l10n/intl_en.arb index 6698bf29f5..b3272b97aa 100644 --- a/packages/espressocash_app/lib/l10n/intl_en.arb +++ b/packages/espressocash_app/lib/l10n/intl_en.arb @@ -48,11 +48,11 @@ "@contactUs": {}, "copiedFid": "Copied FID: {fid}", "@copiedFid": { - "placeholders": { - "fid": { - "type": "String" - } + "placeholders": { + "fid": { + "type": "String" } + } }, "copiedToClipboard": "Copied!", "@copiedToClipboard": {}, @@ -94,14 +94,14 @@ "@insufficientBalance": {}, "insufficientFundsMessage": "You cannot send {amount}, you only have {balance} available.", "@insufficientFundsMessage": { - "placeholders": { - "amount": { - "type": "String" - }, - "balance": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" + }, + "balance": { + "type": "String" } + } }, "inUsdc": "in USDC ", "@inUsdc": {}, @@ -119,35 +119,35 @@ "@loading": {}, "minAmountToOffRamp": "{amount} withdrawal minimum", "@minAmountToOffRamp": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "minAmountToOnRamp": "{amount} deposit minimum", "@minAmountToOnRamp": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "minimumAmountToRequest": "{amount} is the minimum to request", "@minimumAmountToRequest": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "minimumAmountToSend": "{amount} is the minimum to send", "@minimumAmountToSend": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "mobileWalletAcceptAuthorization": "Authorize", "@mobileWalletAcceptAuthorization": {}, @@ -159,11 +159,11 @@ "@mobileWalletAcceptSignTransactions": {}, "mobileWalletAuthorize": "Authorize {appName}?", "@mobileWalletAuthorize": { - "placeholders": { - "appName": { - "type": "String" - } + "placeholders": { + "appName": { + "type": "String" } + } }, "mobileWalletSendTransactions": "These transactions will be immediately submitted after signing.", "@mobileWalletSendTransactions": {}, @@ -173,27 +173,27 @@ "@mobileWalletSignMessages": {}, "mobileWalletSignMessagesRequest": "{appName} is requesting you to sign {count} message(s)", "@mobileWalletSignMessagesRequest": { - "placeholders": { - "appName": { - "type": "String" - }, - "count": { - "type": "int" - } + "placeholders": { + "appName": { + "type": "String" + }, + "count": { + "type": "int" } + } }, "mobileWalletSignTransactions": "Sign Transaction", "@mobileWalletSignTransactions": {}, "mobileWalletSignTransactionsRequest": "{appName} is requesting you to sign {count} transaction(s)", "@mobileWalletSignTransactionsRequest": { - "placeholders": { - "appName": { - "type": "String" - }, - "count": { - "type": "int" - } + "placeholders": { + "appName": { + "type": "String" + }, + "count": { + "type": "int" } + } }, "mobileWalletTitle": "Espresso Cash Wallet", "@mobileWalletTitle": {}, @@ -231,6 +231,8 @@ "@oneMonth": {}, "oneWeek": "1 week", "@oneWeek": {}, + "onRampTopPartnerTitle": "Transfer Money Into Your Espresso Account", + "@onRampTopPartnerTitle": {}, "operationSend": "sent", "@operationSend": {}, "outgoingSplitKeyPayments_btnCancel": "Cancel transfer", @@ -243,11 +245,11 @@ "@outgoingSplitKeyPayments_lblMoneyWithdrawn": {}, "outgoingTransferSuccess": "{amount} has been sent", "@outgoingTransferSuccess": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "pay": "Pay", "@pay": {}, @@ -294,11 +296,11 @@ "@ramp_btnContinue": {}, "rampMinimumTransferAmount": "Min {amount}", "@rampMinimumTransferAmount": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "ramp_titleCashIn": "Deposit", "@ramp_titleCashIn": {}, @@ -356,11 +358,11 @@ "@sentViaLink": {}, "setReminder": "SET A REMINDER FOR {duration}", "@setReminder": { - "placeholders": { - "duration": { - "type": "String" - } + "placeholders": { + "duration": { + "type": "String" } + } }, "share": "Share", "@share": {}, @@ -368,25 +370,25 @@ "@sharePaymentRequestLinkIntro": {}, "sharePaymentRequestLinkMessage": "Hi!\n\nI have sent a request for {amount}. To pay me with one click, visit:\n\n{link}", "@sharePaymentRequestLinkMessage": { - "placeholders": { - "amount": { - "type": "String" - }, - "link": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" + }, + "link": { + "type": "String" } + } }, "shareText": "Hi!\n\nI've just sent you {amount}.\n\nTo receive it, you need to click on the link:\n\n{firstLink}", "@shareText": { - "placeholders": { - "amount": { - "type": "String" - }, - "firstLink": { - "type": "Uri" - } + "placeholders": { + "amount": { + "type": "String" + }, + "firstLink": { + "type": "Uri" } + } }, "signIn1": "Already have a wallet? ", "@signIn1": {}, @@ -402,11 +404,11 @@ "@signUp": {}, "splitKeyCanceledMessage1": "{amount} Transaction successfully canceled.", "@splitKeyCanceledMessage1": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "splitKeyCancelErrorMessage": "There was an issue canceling your transfer.", "@splitKeyCancelErrorMessage": {}, @@ -422,11 +424,11 @@ "@splitKeyProgressCreated": {}, "splitKeyProgressOngoing": "Sending {amount} using secure link...", "@splitKeyProgressOngoing": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "splitKeyProgressSuccess": "Money has been successfully received", "@splitKeyProgressSuccess": {}, @@ -446,19 +448,19 @@ "@to": {}, "todayAt": "Today at {time}", "@todayAt": { - "placeholders": { - "time": { - "type": "String" - } + "placeholders": { + "time": { + "type": "String" } + } }, "tokenEquivalent": "Equivalent to {value}", "@tokenEquivalent": { - "placeholders": { - "value": { - "type": "String" - } + "placeholders": { + "value": { + "type": "String" } + } }, "tomorrow": "Tomorrow", "@tomorrow": {}, @@ -478,14 +480,14 @@ "@usdcInfo": {}, "version": "Version {versionNumber} ({buildNumber})", "@version": { - "placeholders": { - "versionNumber": { - "type": "String" - }, - "buildNumber": { - "type": "String" - } + "placeholders": { + "versionNumber": { + "type": "String" + }, + "buildNumber": { + "type": "String" } + } }, "viewRecoveryPhrase": "View secret recovery phrase", "@viewRecoveryPhrase": {}, @@ -523,11 +525,11 @@ "@yourRecoveryPhraseSub": {}, "zeroAmountMessage": "Please enter an amount greater than zero to be {operation}.", "@zeroAmountMessage": { - "placeholders": { - "operation": { - "type": "String" - } + "placeholders": { + "operation": { + "type": "String" } + } }, "zeroAmountTitle": "Invalid amount", "@zeroAmountTitle": {}, @@ -555,37 +557,37 @@ "@offRampWithdrawSuccess": {}, "offRampWithdrawOngoing": "Sending {amount}", "@offRampWithdrawOngoing": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "offRampWithdrawCancelled": "{amount} withdrawal successfully canceled.", "@offRampWithdrawCancelled": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "offRampWithdrawNotice": "Withdrawals can normally take up to 3 business days to receive.", "@offRampWithdrawNotice": {}, "offRampReceiveAmount": "You'll receive {value}", "@offRampReceiveAmount": { - "placeholders": { - "value": { - "type": "String" - } + "placeholders": { + "value": { + "type": "String" } + } }, "orderId": "Order ID: {orderId}", "@orderId": { - "placeholders": { - "orderId": { - "type": "String" - } + "placeholders": { + "orderId": { + "type": "String" } + } }, "onRampDepositInitiated": "Deposit initiated", "@onRampDepositInitiated": {}, @@ -599,53 +601,59 @@ "@onRampDepositExpired": {}, "onRampLocalTransferTile": "Local currency transferred\nAmount sent: {amount}\n{name}: {account}", "@onRampLocalTransferTile": { - "placeholders": { - "amount": { - "type": "String" - }, - "name": { - "type": "String" - }, - "account": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" + }, + "name": { + "type": "String" + }, + "account": { + "type": "String" } + } }, "onRampDepositOngoing": "Depositing {amount}", "@onRampDepositOngoing": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "onRampAwaitingFunds": "Hang tight. Your order is being processed.", "@onRampAwaitingFunds": {}, "incomingUsdcFeeNotice": "You were charged a one time {amount} fee for your first incoming transaction.", "@incomingUsdcFeeNotice": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "depositTitle": "Deposit", "@depositTitle": {}, - "depositInstruction1": "Transfer money from your bank account to our local partner:", - "@depositInstruction1": {}, - "depositInstruction2": "Once money has been transferred, return to Espresso Cash and continue below.", + "depositInstruction1": "Transfer the deposit amount from your bank account to our local partners {bankName}:", + "@depositInstruction1": { + "placeholders": { + "bankName": { + "type": "String" + } + } + }, + "depositInstruction2": "Once the transfer is complete, return to Espresso Cash and confirm below.", "@depositInstruction2": {}, - "depositTransferAmount": "Amount to be transferred", + "depositTransferAmount": "Deposit Amount", "@depositTransferAmount": {}, "depositDisclaimer": "Funds must be sent from your personal account. Any funds sent through an account that does not match your identification details will be reserved.", "@depositDisclaimer": {}, "depositReceiveAmount": "You will receive: {amount}", "@depositReceiveAmount": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "quizCorrect": "Correct!", "@quizCorrect": {}, @@ -703,11 +711,11 @@ "@transactionAwaitingFulfillment": {}, "paymentProgressOngoing": "Sending {amount}", "@paymentProgressOngoing": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "outgoingDlnDisclaimer1": "This feature is currently in BETA.\nPlease contact ", "@outgoingDlnDisclaimer1": {}, @@ -744,11 +752,11 @@ "@addCash_sendReceiveMoney": {}, "invoiceNumber": "Invoice {reference}", "@invoiceNumber": { - "placeholders": { - "reference": { - "type": "String" - } + "placeholders": { + "reference": { + "type": "String" } + } }, "received": "Received", "@received": {}, @@ -770,11 +778,11 @@ "@confirmYourTransferOf": {}, "transferInProgressText": "Transfer could take up to {minutes} minutes...", "@transferInProgressText": { - "placeholders": { - "minutes": { - "type": "int" - } + "placeholders": { + "minutes": { + "type": "int" } + } }, "openMoneygramIframeText": "Open Moneygram to continue", "@openMoneygramIframeText": {}, @@ -802,11 +810,11 @@ "@moneyRecoveryBtn": {}, "moneyRecoveryContent": "{amount} in unclaimed money has been detected and is ready for recovery.", "@moneyRecoveryContent": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "moneyRecoverySubContent": "The recovered amount will be added to your Espresso Cash account balance.", "@moneyRecoverySubContent": {}, @@ -824,11 +832,11 @@ "@moneyRecoveryFailure": {}, "moneyRecoverySuccess": "{amount} has been successfully added to your balance.", "@moneyRecoverySuccess": { - "placeholders": { - "amount": { - "type": "String" - } + "placeholders": { + "amount": { + "type": "String" } + } }, "offRampWithdrawalInProgress": "Withdrawing in progress...", "@offRampWithdrawalInProgress": {}, @@ -913,5 +921,199 @@ "ambassador_notAmbassadorTitle": "Invalid Ambassador", "@ambassador_notAmbassadorTitle": {}, "ambassador_notAmbassadorDescription": "The QR code you scanned does not\nbelong to an ambassador.", - "@ambassador_notAmbassadorDescription": {} + "@ambassador_notAmbassadorDescription": {}, + "failedToUpdateData": "Failed to update data", + "@failedToUpdateData": {}, + "bankAccount": "Bank Account", + "@bankAccount": {}, + "accountNumber": "Account Number", + "@accountNumber": {}, + "bankCode": "Bank Code", + "@bankCode": {}, + "bankName": "Bank Name", + "@bankName": {}, + "basicInformation": "Basic Information", + "@basicInformation": {}, + "firstName": "First Name", + "@firstName": {}, + "lastName": "Last Name", + "@lastName": {}, + "dateOfBirth": "Date of Birth (dd/mm/yyyy)", + "@dateOfBirth": {}, + "idNumber": "ID Number", + "@idNumber": {}, + "allowShareDataText": "Allow Espresso Cash partners to share this data for the purposes of deposits and withdrawals.", + "@allowShareDataText": {}, + "selectIdMethod": "Select ID method", + "@selectIdMethod": {}, + "emailVerification": "Email verification", + "@emailVerification": {}, + "enterVerificationCode": "Enter Verification Code", + "@enterVerificationCode": {}, + "checkEmailText": "Check your email. We've sent the code to {email}", + "@checkEmailText": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "sendVerificationCode": "Send Verification Code", + "@sendVerificationCode": {}, + "enterEmailHintText": "Enter your email address to receive your confirmation code.", + "@enterEmailHintText": {}, + "emailAddress": "Email Address", + "@emailAddress": {}, + "phoneVerification": "Phone verification", + "@phoneVerification": {}, + "enterPhoneNumberHintText": "Enter your mobile phone number to receive your confirmation code.", + "@enterPhoneNumberHintText": {}, + "phoneNumber": "Phone Number", + "@phoneNumber": {}, + "checkSmsText": "Check your text messages. We've sent the code to {phone}", + "@checkSmsText": { + "placeholders": { + "phone": { + "type": "String" + } + } + }, + "submit": "Submit", + "@submit": {}, + "retakeSelfie": "Retake Selfie", + "@retakeSelfie": {}, + "identityVerification": "Identity Verification", + "@identityVerification": {}, + "startSelfieVerification": "Start Selfie Verification", + "@startSelfieVerification": {}, + "identityVerificationDescription": "For security purposes, we need you to take a quick selfie to verify your identity.", + "@identityVerificationDescription": {}, + "identityInstruction1": "Make sure your face is clearly visible.", + "@identityInstruction1": {}, + "identityInstruction2": "Avoid hats, sunglasses, or other facial coverings.", + "@identityInstruction2": {}, + "identityInstruction3": "Good lighting helps us verify your identity quickly!", + "@identityInstruction3": {}, + "manageDataAccess": "Manage Data Access", + "@manageDataAccess": {}, + "accountProfile": "Account Profile", + "@accountProfile": {}, + "begin": "Begin", + "@begin": {}, + "reVerificationNotice": "Re-verification is only needed if you update or change your ID.", + "@reVerificationNotice": {}, + "onRampKycInitialDescription": "*Complete ID verification* to proceed with your deposit.", + "@onRampKycInitialDescription": {}, + "offRampKycInitialDescription": "*Complete ID verification* to proceed with your withdrawal.", + "@offRampKycInitialDescription": {}, + "bankAccountInfoCorrectText": "Ensure your account info is correct to prevent delays.", + "@bankAccountInfoCorrectText": {}, + "verified": "Verified", + "@verified": {}, + "notVerified": "Not Verified", + "@notVerified": {}, + "pendingApproval": "*Pending* approval", + "@pendingApproval": {}, + "verificationFailed": "Verification failed", + "@verificationFailed": {}, + "verificationNotStarted": "Verification not started", + "@verificationNotStarted": {}, + "kycStatusApprovedDescription": "Great news!\nYour verification has been approved", + "@kycStatusApprovedDescription": {}, + "kycStatusPendingDescription": "Your verification is being reviewed.\nYou can proceed once approved.", + "@kycStatusPendingDescription": {}, + "kycStatusFailedDescription": "Your verification was not approved.", + "@kycStatusFailedDescription": {}, + "kycTimelinePendingItem1": "The review process can take 10-20 minutes.", + "@kycTimelinePendingItem1": {}, + "kycTimelinePendingItem2": "Don't worry, you can still send and receive money while your review is being processed.", + "@kycTimelinePendingItem2": {}, + "kycTimelinePendingItem3": "Verification status updates will appear in your activity notifications.", + "@kycTimelinePendingItem3": {}, + "kycTimelineApprovedItem1": "You can now make deposits and withdrawals.", + "@kycTimelineApprovedItem1": {}, + "kycTimelineApprovedItem2": "Your verification covers all future transactions.", + "@kycTimelineApprovedItem2": {}, + "kycTimelineApprovedItem3": "Re-verification will only be required if you change your identification.", + "@kycTimelineApprovedItem3": {}, + "kycTimelineRejectedItem1": "Make sure all your information is correct.", + "@kycTimelineRejectedItem1": {}, + "kycTimelineRejectedItem2": "Make sure your ID verification method is current.", + "@kycTimelineRejectedItem2": {}, + "returnToDashboard": "Return to Dashboard", + "@returnToDashboard": {}, + "continueDeposit": "Continue Deposit", + "@continueDeposit": {}, + "continueWithdrawal": "Continue Withdrawal", + "@continueWithdrawal": {}, + "seeDetails": "See details", + "@seeDetails": {}, + "retryVerification": "Retry verification", + "@retryVerification": {}, + "kycTileDescriptionApproved": "Your ID verification has been approved.", + "@kycTileDescriptionApproved": {}, + "kycTileDescriptionPending": "Your ID verification is under review and may take 10–20 minutes to complete.", + "@kycTileDescriptionPending": {}, + "kycTileDescriptionRejected": "Your ID verification failed.\nMake sure your information is correct and try again.", + "@kycTileDescriptionRejected": {}, + "kycTileDescriptionUnverified": "Start your ID verification to continue.", + "@kycTileDescriptionUnverified": {}, + "wrongVerificationCode": "Wrong verification code", + "@wrongVerificationCode": {}, + "invalidEmail": "Invalid email", + "@invalidEmail": {}, + "invalidPhone": "Invalid phone", + "@invalidPhone": {}, + "failedToSendVerificationCode": "Failed to send verification code", + "@failedToSendVerificationCode": {}, + "error": "Error", + "@error": {}, + "userDescriptionItem1Text": "DOB: {dob}", + "@userDescriptionItem1Text": { + "placeholders": { + "dob": { + "type": "String" + } + } + }, + "userDescriptionItem2Text": "ID Method: {idType}", + "@userDescriptionItem2Text": { + "placeholders": { + "idType": { + "type": "String" + } + } + }, + "userDescriptionItem3Text": "ID Number: {documentNumber}", + "@userDescriptionItem3Text": { + "placeholders": { + "documentNumber": { + "type": "String" + } + } + }, + "bankDescriptionItem1Text": "Country: {countryName}", + "@bankDescriptionItem1Text": { + "placeholders": { + "countryName": { + "type": "String" + } + } + }, + "bankDescriptionItem2Text": "Account Number: {accountNumber}", + "@bankDescriptionItem2Text": { + "placeholders": { + "accountNumber": { + "type": "String" + } + } + }, + "bankDescriptionItem3Text": "Bank Code: {bankCode}", + "@bankDescriptionItem3Text": { + "placeholders": { + "bankCode": { + "type": "String" + } + } + } } diff --git a/packages/espressocash_app/lib/main.dart b/packages/espressocash_app/lib/main.dart index 4ed7d02a9d..5d9a71aa75 100644 --- a/packages/espressocash_app/lib/main.dart +++ b/packages/espressocash_app/lib/main.dart @@ -1,4 +1,5 @@ import 'package:device_preview/device_preview.dart'; +import 'package:face_camera/face_camera.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -54,6 +55,8 @@ Future _init([ISentrySpan? span]) async { setUpLogging(); + await FaceCamera.initialize(); + final sharedPreferences = sl(); final hasPassedFirstRun = sharedPreferences.getBool(_firstRunKey) ?? false; if (!hasPassedFirstRun) { diff --git a/packages/espressocash_app/lib/storybook/stories/screens/off_ramp_amount_screen.dart b/packages/espressocash_app/lib/storybook/stories/screens/off_ramp_amount_screen.dart index a0045d155f..99dd9765ec 100644 --- a/packages/espressocash_app/lib/storybook/stories/screens/off_ramp_amount_screen.dart +++ b/packages/espressocash_app/lib/storybook/stories/screens/off_ramp_amount_screen.dart @@ -4,9 +4,9 @@ import 'package:storybook_flutter/storybook_flutter.dart'; import '../../../features/currency/models/amount.dart'; import '../../../features/currency/models/currency.dart'; -import '../../../features/ramp/models/ramp_type.dart'; import '../../../features/ramp/screens/ramp_amount_screen.dart'; import '../../../features/ramp_partner/models/ramp_partner.dart'; +import '../../../features/ramp_partner/models/ramp_type.dart'; import '../../utils.dart'; final offRampAmountScreenStory = Story( @@ -52,5 +52,6 @@ final offRampAmountScreenStory = Story( ), exchangeRate: null, receiveCurrency: null, + initialAmount: null, ), ); diff --git a/packages/espressocash_app/lib/storybook/stories/screens/ramp_partner_select_screen.dart b/packages/espressocash_app/lib/storybook/stories/screens/ramp_partner_select_screen.dart index 231d1e8623..da428d7d2f 100644 --- a/packages/espressocash_app/lib/storybook/stories/screens/ramp_partner_select_screen.dart +++ b/packages/espressocash_app/lib/storybook/stories/screens/ramp_partner_select_screen.dart @@ -3,8 +3,8 @@ import 'package:dfunc/dfunc.dart'; import 'package:storybook_flutter/storybook_flutter.dart'; -import '../../../features/ramp/models/ramp_type.dart'; import '../../../features/ramp/screens/ramp_partner_select_screen.dart'; +import '../../../features/ramp_partner/models/ramp_type.dart'; import '../../app_wrapper.dart'; import '../../utils.dart'; diff --git a/packages/espressocash_app/lib/ui/button.dart b/packages/espressocash_app/lib/ui/button.dart index 8219319cd0..ef9d3f01a3 100644 --- a/packages/espressocash_app/lib/ui/button.dart +++ b/packages/espressocash_app/lib/ui/button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'colors.dart'; @@ -86,26 +87,26 @@ class CpButton extends StatelessWidget { case CpButtonSize.normal: return style; case CpButtonSize.big: - return style.copyWith(fontSize: 17); + return style.copyWith(fontSize: 17.sp); case CpButtonSize.small: - return style.copyWith(fontSize: 17, height: 1); + return style.copyWith(fontSize: 17.sp, height: 1); case CpButtonSize.wide: - return style.copyWith(fontSize: 16, height: 0); + return style.copyWith(fontSize: 16.sp, height: 0); case CpButtonSize.micro: - return style.copyWith(fontSize: 15, height: 0); + return style.copyWith(fontSize: 15.sp, height: 0); } })(); final double horizontalPadding; switch (size) { case CpButtonSize.micro: - horizontalPadding = 8; + horizontalPadding = 8.w; case CpButtonSize.wide: - horizontalPadding = 4; + horizontalPadding = 4.w; case CpButtonSize.normal: case CpButtonSize.big: case CpButtonSize.small: - horizontalPadding = 16; + horizontalPadding = 16.w; } final button = TextButton( @@ -113,7 +114,7 @@ class CpButton extends StatelessWidget { style: ButtonStyle( animationDuration: Duration.zero, minimumSize: - WidgetStateProperty.all(Size(minWidth ?? 100, size.height)), + WidgetStateProperty.all(Size(minWidth ?? 100.w, size.height)), fixedSize: WidgetStateProperty.all( Size.fromHeight(size.height), ), @@ -128,11 +129,7 @@ class CpButton extends StatelessWidget { ? _backgroundColor.withOpacity(_disabledOpacity) : _backgroundColor, ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) - ? _foregroundColor.withOpacity(_disabledOpacity) - : _foregroundColor, - ), + foregroundColor: WidgetStateProperty.all(_foregroundColor), textStyle: WidgetStateProperty.all(textStyle), ), child: SizedBox( @@ -178,14 +175,14 @@ extension CpButtonSizeExt on CpButtonSize { double get height { switch (this) { case CpButtonSize.normal: - return 51; + return 51.h; case CpButtonSize.big: - return 64; + return 64.h; case CpButtonSize.wide: case CpButtonSize.small: - return 44; + return 44.h; case CpButtonSize.micro: - return 30; + return 30.h; } } } diff --git a/packages/espressocash_app/lib/ui/colors.dart b/packages/espressocash_app/lib/ui/colors.dart index 9a500d80aa..59535e4542 100644 --- a/packages/espressocash_app/lib/ui/colors.dart +++ b/packages/espressocash_app/lib/ui/colors.dart @@ -12,6 +12,7 @@ abstract class CpColors { static const darkSandColor = Color(0xFF9E8B5A); static const alertRedColor = Color(0xffe8452f); static const greyColor = Color(0xff999999); + static const lightGreyColor = Color(0xff444444); static const deepGreyColor = Color(0xFF2D2B2C); static const blackGreyColor = Color(0xff181818); diff --git a/packages/espressocash_app/lib/ui/dob_text_field.dart b/packages/espressocash_app/lib/ui/dob_text_field.dart new file mode 100644 index 0000000000..0140fc7f99 --- /dev/null +++ b/packages/espressocash_app/lib/ui/dob_text_field.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'colors.dart'; +import 'text_field.dart'; + +class CpDobTextField extends StatelessWidget { + const CpDobTextField({ + super.key, + required this.controller, + required this.placeholder, + }); + + final TextEditingController controller; + final String placeholder; + + @override + Widget build(BuildContext context) => CpTextField( + padding: const EdgeInsets.only( + top: 18, + bottom: 16, + left: 26, + right: 26, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9-/]')), + LengthLimitingTextInputFormatter(10), + const _DateInputFormatter(), + ], + fontWeight: FontWeight.w500, + fontSize: 16, + controller: controller, + inputType: TextInputType.datetime, + textInputAction: TextInputAction.next, + backgroundColor: CpColors.lightGreyColor, + placeholder: placeholder, + placeholderColor: Colors.white, + textColor: Colors.white, + ); +} + +class _DateInputFormatter extends TextInputFormatter { + const _DateInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue previousValue, + TextEditingValue currentValue, + ) { + final String currentText = currentValue.text; + + final int currentLength = currentText.length; + final int previousLength = previousValue.text.length; + + if (currentLength == 1) { + if (int.tryParse(currentText) == null || int.parse(currentText) > 3) { + return _updateText(''); + } + } + + if (currentLength == 2 && previousLength == 1) { + final int day = int.tryParse(currentText.substring(0, 2)) ?? 0; + if (day < 1 || day > 31) { + return _updateText(currentText.substring(0, 1)); + } + + return _updateText('$currentText/'); + } + + if (currentLength == 4) { + if (int.tryParse(currentText.substring(3, 4)) == null || + int.parse(currentText.substring(3, 4)) > 1) { + return _updateText(currentText.substring(0, 3)); + } + } + + if (currentLength == 5 && previousLength == 4) { + final int month = int.tryParse(currentText.substring(3, 5)) ?? 0; + if (month < 1 || month > 12) { + return _updateText(currentText.substring(0, 4)); + } + + return _updateText('$currentText/'); + } + + if ((currentLength == 3 && previousLength == 4) || + (currentLength == 6 && previousLength == 7)) { + return _updateText(currentText.substring(0, currentText.length - 1)); + } + + if (currentLength == 3 && previousLength == 2) { + if (!currentText.contains('/')) { + return _updateText( + '${currentText.substring(0, 2)}/${currentText.substring(2)}', + ); + } + } + + if (currentLength == 6 && previousLength == 5) { + if (!currentText.contains('/', 5)) { + return _updateText( + '${currentText.substring(0, 5)}/${currentText.substring(5)}', + ); + } + } + + if (currentLength == 7) { + if (int.tryParse(currentText.substring(6, 7)) == null || + int.parse(currentText.substring(6, 7)) > 2) { + return _updateText(currentText.substring(0, 6)); + } + } + + if (currentLength == 8) { + final int yearPrefix = int.tryParse(currentText.substring(6, 8)) ?? 0; + if (yearPrefix < 19 || yearPrefix > 20) { + return _updateText(currentText.substring(0, 7)); + } + } + + return _updateText(currentText); + } + + TextEditingValue _updateText(String text) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); +} diff --git a/packages/espressocash_app/lib/ui/info_list.dart b/packages/espressocash_app/lib/ui/info_list.dart new file mode 100644 index 0000000000..8239c92eb2 --- /dev/null +++ b/packages/espressocash_app/lib/ui/info_list.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import 'colors.dart'; +import 'info_icon.dart'; + +enum CpInfoListVariant { light, yellow } + +class CpInfoListItem { + const CpInfoListItem({ + this.variant = CpInfoListVariant.yellow, + this.title, + this.subtitle, + this.trailing, + }); + + final CpInfoListVariant variant; + final String? title; + final String? subtitle; + final String? trailing; +} + +class CpInfoList extends StatefulWidget { + const CpInfoList({ + super.key, + required this.items, + }) : assert(items.length > 0, 'Items must not be empty'); + + final List items; + + @override + State createState() => _State(); +} + +class _State extends State with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) => ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.items.length, + itemBuilder: (context, index) { + final isFirst = index == 0; + final isLast = index == widget.items.length - 1; + + final indicatorColor = widget.items[index].variant.backgroundColor; + + final connectorColor = isLast + ? indicatorColor + : widget.items[index + 1].variant.backgroundColor; + + return Row( + key: ValueKey(index), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + _Indicator( + isFirst: isFirst, + isLast: isLast, + backgroundColor: indicatorColor, + ), + if (!isLast) + _Connector( + color: connectorColor, + ), + ], + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: isFirst ? 6 : 0), + child: _TileInfo(tile: widget.items[index]), + ), + ), + ], + ); + }, + ); +} + +class _TileInfo extends StatelessWidget { + const _TileInfo({ + required this.tile, + }); + + final CpInfoListItem tile; + + @override + Widget build(BuildContext context) { + final title = tile.title; + final subtitle = tile.subtitle; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text(title, style: _titleStyle), + const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + ], + if (subtitle != null) Text(subtitle, style: _subtitleStyle), + ], + ), + ), + Text(tile.trailing ?? '', style: _titleStyle), + ], + ), + ); + } +} + +class _Indicator extends StatelessWidget { + const _Indicator({ + required this.isFirst, + required this.isLast, + required this.backgroundColor, + }); + + final bool isFirst; + final bool isLast; + final Color backgroundColor; + + @override + Widget build(BuildContext context) => Container( + width: _timelineWidth, + margin: const EdgeInsets.symmetric(vertical: 6), + child: SizedBox( + height: _indicatorSize, + child: CircleAvatar( + maxRadius: _indicatorSize, + backgroundColor: backgroundColor, + child: _icon, + ), + ), + ); +} + +class _Connector extends StatelessWidget { + const _Connector({ + required this.color, + }); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + height: _connectorHeight, + width: _connectorWidth, + decoration: BoxDecoration( + color: color, + borderRadius: + const BorderRadius.all(Radius.circular(_connectorRadius)), + ), + ); +} + +extension on CpInfoListVariant { + Color get backgroundColor { + switch (this) { + case CpInfoListVariant.yellow: + return CpColors.infoBackgroundColor; + case CpInfoListVariant.light: + return CpColors.lightButtonBackgroundColor; + } + } +} + +const _icon = CpInfoIcon(height: 16, iconColor: Colors.black); + +const _titleStyle = TextStyle(fontWeight: FontWeight.w500, fontSize: 16); +const _subtitleStyle = TextStyle(fontWeight: FontWeight.w400, fontSize: 14); +const _timelineWidth = 65.0; +const _connectorHeight = 57.0; +const _connectorWidth = 7.0; +const _connectorRadius = 51.0; +const _indicatorSize = 30.0; diff --git a/packages/espressocash_app/lib/ui/radio_button.dart b/packages/espressocash_app/lib/ui/radio_button.dart new file mode 100644 index 0000000000..45ba5f9a96 --- /dev/null +++ b/packages/espressocash_app/lib/ui/radio_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'colors.dart'; + +class CpRadioButton extends StatelessWidget { + const CpRadioButton({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: () => onChanged(!value), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: value ? CpColors.yellowColor : CpColors.blackGreyColor, + ), + ), + ); +} diff --git a/packages/espressocash_app/lib/ui/text_field.dart b/packages/espressocash_app/lib/ui/text_field.dart index aaa89af890..2b225198a0 100644 --- a/packages/espressocash_app/lib/ui/text_field.dart +++ b/packages/espressocash_app/lib/ui/text_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'colors.dart'; @@ -12,20 +13,22 @@ class CpTextField extends StatelessWidget { this.placeholder, this.margin, this.inputType, + this.inputFormatters, this.placeholderColor = CpColors.placeholderLightColor, this.backgroundColor = CpColors.lightGreyBackground, this.readOnly = false, this.fontSize = 20, + this.fontWeight = FontWeight.normal, this.disabled = false, this.padding = const EdgeInsets.all(24), this.suffix, this.border = CpTextFieldBorder.stadium, this.prefix, this.textColor = CpColors.primaryTextColor, + this.textAlign = TextAlign.start, this.textInputAction, this.multiLine = false, this.textCapitalization = TextCapitalization.none, - this.fontWeight = FontWeight.normal, }); final TextEditingController? controller; @@ -36,16 +39,18 @@ class CpTextField extends StatelessWidget { final EdgeInsetsGeometry? margin; final bool readOnly; final double fontSize; + final FontWeight fontWeight; final bool disabled; final TextInputType? inputType; + final List? inputFormatters; final Widget? suffix; final Widget? prefix; final CpTextFieldBorder border; final Color? textColor; + final TextAlign textAlign; final TextInputAction? textInputAction; final bool? multiLine; final TextCapitalization textCapitalization; - final FontWeight fontWeight; @override Widget build(BuildContext context) { @@ -79,8 +84,10 @@ class CpTextField extends StatelessWidget { color: textColor, height: 1.2, ), + textAlign: textAlign, placeholder: placeholder, keyboardType: inputType, + inputFormatters: inputFormatters, textCapitalization: textCapitalization, keyboardAppearance: Theme.of(context).brightness, placeholderStyle: TextStyle(color: placeholderColor), diff --git a/packages/espressocash_app/lib/ui/timeline.dart b/packages/espressocash_app/lib/ui/timeline.dart index d0d72917c2..8965a18557 100644 --- a/packages/espressocash_app/lib/ui/timeline.dart +++ b/packages/espressocash_app/lib/ui/timeline.dart @@ -10,12 +10,12 @@ typedef _AnimationTransformer = double Function(double value); class CpTimelineItem { const CpTimelineItem({ - required this.title, + this.title, this.subtitle, this.trailing, }); - final String title; + final String? title; final String? subtitle; final String? trailing; } @@ -163,6 +163,7 @@ class _TileInfo extends StatelessWidget { @override Widget build(BuildContext context) { + final title = tile.title; final subtitle = tile.subtitle; return Padding( @@ -174,8 +175,10 @@ class _TileInfo extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tile.title, style: _titleStyle), - const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + if (title != null) ...[ + Text(title, style: _titleStyle), + const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + ], if (subtitle != null) Text(subtitle, style: _subtitleStyle), ], ), diff --git a/packages/espressocash_app/pubspec.lock b/packages/espressocash_app/pubspec.lock index 11b53f9efb..ea1d9d29c1 100644 --- a/packages/espressocash_app/pubspec.lock +++ b/packages/espressocash_app/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: b46f62516902afb04befa4b30eb6a12ac1f58ca8cb25fb9d632407259555dd3d + sha256: "9371d13b8ee442e3bfc08a24e3a1b3742c839abbfaf5eef11b79c4b862c89bf7" url: "https://pub.dev" source: hosted - version: "1.3.39" + version: "1.3.41" adaptive_number: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: ansicolor - sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" archive: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + base_x: + dependency: transitive + description: + name: base_x + sha256: "519abcdafd637d4b6bd7e72fabd8f9264935f804b9b9f6c5d8411c7d52cbf8fd" + url: "https://pub.dev" + source: hosted + version: "2.0.1" bip39: dependency: "direct main" description: @@ -145,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1+5" + bs58: + dependency: transitive + description: + name: bs58 + sha256: "3ed24dadf386ca749ff50af678be1131ef569a3583a6f37b87b90c032270c767" + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -213,26 +229,66 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.1" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" + camera: + dependency: transitive + description: + name: camera + sha256: "26ff41045772153f222ffffecba711a206f670f5834d40ebf5eed3811692f167" + url: "https://pub.dev" + source: hosted + version: "0.11.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "011be2ab0e5b3e3aa8094413fa890f8c5c5afd7cfdaef353a992047d4dab5780" + url: "https://pub.dev" + source: hosted + version: "0.6.8+2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "7c28969a975a7eb2349bc2cb2dfe3ad218a33dba9968ecfb181ce08c87486655" + url: "https://pub.dev" + source: hosted + version: "0.9.17+3" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: b3ede1f171532e0d83111fe0980b46d17f1aa9788a07a2fbed07366bbdbb9061 + url: "https://pub.dev" + source: hosted + version: "2.8.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + url: "https://pub.dev" + source: hosted + version: "0.3.5" characters: dependency: transitive description: @@ -317,26 +373,26 @@ packages: dependency: transitive description: name: coverage - sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.2" cross_file: dependency: transitive description: name: cross_file - sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.4+1" + version: "0.3.4+2" crypto: dependency: "direct main" description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" cryptography: dependency: transitive description: @@ -381,10 +437,10 @@ packages: dependency: "direct main" description: name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" + sha256: "4140a688f9e443e2f4de3a1162387bf25e1ac6d51e24c9da263f245210f41440" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "3.0.2" device_frame: dependency: transitive description: @@ -421,10 +477,10 @@ packages: dependency: "direct main" description: name: dio - sha256: e17f6b3097b8c51b72c74c9f071a605c47bcc8893839bd66732457a5ebe73714 + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" url: "https://pub.dev" source: hosted - version: "5.5.0+1" + version: "5.7.0" dio_cache_interceptor: dependency: "direct main" description: @@ -445,10 +501,10 @@ packages: dependency: transitive description: name: dio_web_adapter - sha256: "36c5b2d79eb17cdae41e974b7a8284fec631651d2a6f39a8a2ff22327e90aeac" + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.0" drift: dependency: "direct main" description: @@ -477,10 +533,10 @@ packages: dependency: transitive description: name: ed25519_hd_key - sha256: c5c9f11a03f5789bf9dcd9ae88d641571c802640851f1cacdb13123f171b3a26 + sha256: "0d3a58aa81474bfa9ff67b0d8252d73890276be9801cfccf5f1e1bb6b92ac5c6" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.1.0" espressocash_api: dependency: "direct main" description: @@ -496,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + face_camera: + dependency: "direct main" + description: + name: face_camera + sha256: "002b95b4c12472c8a652589620bf1f620f82ba7473dba923f978d14b9cd66b86" + url: "https://pub.dev" + source: hosted + version: "0.1.2" fake_async: dependency: transitive description: @@ -516,10 +580,10 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" file: dependency: transitive description: @@ -556,82 +620,82 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+2" firebase_app_installations: dependency: "direct main" description: name: firebase_app_installations - sha256: bf9b70795604ed7028f91b8d2099127ddb6496ef976b19f80566cd6bfcb85d70 + sha256: "4c5b9aaa2e47e43093dc70e9ea908b89f9bcb17fcb9868da446bf5e2a1830d25" url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.3.1+1" firebase_app_installations_platform_interface: dependency: transitive description: name: firebase_app_installations_platform_interface - sha256: "41696fc7300294b65d95947f2414535aa1f49ed489452e5625fca2277e8e80a8" + sha256: "70cfa6e2257df9bc1df70710cc2ce9d7581123f281ff26dabbb31218bdc95e76" url: "https://pub.dev" source: hosted - version: "0.1.4+39" + version: "0.1.4+41" firebase_app_installations_web: dependency: transitive description: name: firebase_app_installations_web - sha256: "394580842360e377d324d053514cbda5049510621e949a08ab26086f19ca54b6" + sha256: "74911ba3644c38993cb92760ddaa23cc9389c30b089bbb7700c51b4a7192d894" url: "https://pub.dev" source: hosted - version: "0.1.5+11" + version: "0.1.5+13" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "5159984ce9b70727473eb388394650677c02c925aaa6c9439905e1f30966a4d5" + sha256: "06537da27db981947fa535bb91ca120b4e9cb59cb87278dbdde718558cafc9ff" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.4.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb" + sha256: f7d7180c7f99babd4b4c517754d41a09a4943a0f7a69b65c894ca5c68ba66315 url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.2.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "23509cb3cddfb3c910c143279ac3f07f06d3120f7d835e4a5d4b42558e978712" + sha256: "362e52457ed2b7b180964769c1e04d1e0ea0259fdf7025fdfedd019d4ae2bd88" url: "https://pub.dev" source: hosted - version: "2.17.3" + version: "2.17.5" firebase_remote_config: dependency: "direct main" description: name: firebase_remote_config - sha256: aa150fcbaa1fe5afcb912ccf6a059f1a8ef8566dceccaa45ff72c8498ca2103e + sha256: b5c23fb7f5b8fd2338f512587a8d2714b5b81dc02508a1c16163c51c1aa41991 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.1.0" firebase_remote_config_platform_interface: dependency: transitive description: name: firebase_remote_config_platform_interface - sha256: "0c4f4b473074ab37b069360629998dbc7175c334afc249051d8ad590521741a8" + sha256: "127ebc8b7c905d211396cab3b0984e4436c6350400805d196607043b8984d09c" url: "https://pub.dev" source: hosted - version: "1.4.39" + version: "1.4.41" firebase_remote_config_web: dependency: transitive description: name: firebase_remote_config_web - sha256: "45fcb61f5bd46eada6dc11d7167512e3441db50cfadd05394272fc1fdce4a999" + sha256: "29dbff195c6225f957af541d325426f1697710ac36d169431c95bc92d985f4d2" url: "https://pub.dev" source: hosted - version: "1.6.11" + version: "1.6.13" fixnum: dependency: transitive description: @@ -657,10 +721,10 @@ packages: dependency: transitive description: name: flutter_cache_manager - sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.1" flutter_driver: dependency: "direct dev" description: flutter @@ -670,18 +734,18 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: d8e828ad015a8511624491b78ad8e3f86edb7993528b1613aefbb4ad95947795 + sha256: "638d518897f1aefc55a24278968027591d50223a6943b6ae9aa576fe1494d99d" url: "https://pub.dev" source: hosted - version: "5.6.0" + version: "5.7.0" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: "931b03f77c164df0a4815aac0efc619a6ac8ec4cada55025119fca4894dada90" + sha256: "7f2f02d95e3ec96cf70a1c515700c0dd3ea905af003303a55d6fb081240e6b8a" url: "https://pub.dev" source: hosted - version: "5.6.0" + version: "5.7.0" flutter_inappwebview: dependency: "direct main" description: @@ -748,26 +812,26 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "2e8a801b1ded5ea001a4529c97b1f213dcb11c6b20668e081cafb23468593514" + sha256: a23c41ee57573e62fc2190a1f36a0480c4d90bde3a8a8d7126e5d5992fb53fb7 url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.3+1" flutter_native_splash: dependency: "direct dev" description: name: flutter_native_splash - sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da" + sha256: aa06fec78de2190f3db4319dd60fdc8d12b2626e93ef9828633928c2dcaea840 url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.4.1" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.0.22" flutter_screenutil: dependency: "direct main" description: @@ -862,10 +926,10 @@ packages: dependency: "direct main" description: name: freezed_annotation - sha256: f9f6597ac43cc262fa7d7f2e65259a6060c23a560525d1f2631be374540f2a9b + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.4" frontend_server_client: dependency: transitive description: @@ -903,14 +967,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.0" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "27d626c66a181351a953eba5b6ff1ff123aadb891b4dab085b292118f039d6ac" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + google_mlkit_face_detection: + dependency: transitive + description: + name: google_mlkit_face_detection + sha256: "5b597061cafe4dfa70f66adddadd19381eb88bd3312b074528c62b246392304b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" hashcodes: dependency: transitive description: @@ -939,10 +1019,10 @@ packages: dependency: "direct main" description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -979,18 +1059,18 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "4161e1f843d8480d2e9025ee22411778c3c9eb7e40076dcf2da23d8242b7b51c" + sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56" url: "https://pub.dev" source: hosted - version: "0.8.12+3" + version: "0.8.12+12" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.5" image_picker_ios: dependency: transitive description: @@ -1043,10 +1123,10 @@ packages: dependency: "direct main" description: name: injectable - sha256: "3c8355a29d11ff28c0311bed754649761f345ef7a13ff66a714380954af51226" + sha256: "69874ba3ec10e3a0de3f519a184442878291d928f3299d718813f24642585198" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" injectable_generator: dependency: "direct dev" description: @@ -1064,10 +1144,10 @@ packages: dependency: "direct main" description: name: intercom_flutter - sha256: fe7ed0f93e8f4747c06ed5207eb15bb1510ea77cd2491ae45f7b81ab0c5c38b2 + sha256: "4eeb6ecc55cc8d0a5b44324e2f423dab4b242041794af555cb9c1d4a3c2efa95" url: "https://pub.dev" source: hosted - version: "9.0.7" + version: "9.0.9" intercom_flutter_platform_interface: dependency: transitive description: @@ -1147,6 +1227,15 @@ packages: relative: true source: path version: "0.0.4" + kyc_client_dart: + dependency: "direct main" + description: + path: "." + ref: ca8414ca31a5995a0dc47e7fdcf4841826df315e + resolved-ref: ca8414ca31a5995a0dc47e7fdcf4841826df315e + url: "https://github.com/espresso-cash/kyc_client_dart.git" + source: git + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -1223,10 +1312,10 @@ packages: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" mixpanel_flutter: dependency: "direct main" description: @@ -1239,10 +1328,10 @@ packages: dependency: "direct main" description: name: mobile_scanner - sha256: b8c0e9afcfd52534f85ec666f3d52156f560b5e6c25b1e3d4fe2087763607926 + sha256: "6ac2913ad98c83f558d2c8a55bc8f511bdcf28b86639701c04b04c16da1e9ee1" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.1" mockito: dependency: "direct dev" description: @@ -1279,10 +1368,10 @@ packages: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" package_config: dependency: transitive description: @@ -1295,18 +1384,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: b93d8b4d624b4ea19b0a5a208b2d6eff06004bc3ce74c06040b120eeadd00ce0 + sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.0.2" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: f49918f3433a3146047372f9d4f1f847511f2acd5cd030e1f44fe5a50036b70e + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" path: dependency: "direct main" description: @@ -1327,18 +1416,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "30c5aa827a6ae95ce2853cdc5fe3971daaac00f6f081c419c013f7f57bff2f5e" + sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" url: "https://pub.dev" source: hosted - version: "2.2.7" + version: "2.2.10" path_provider_foundation: dependency: transitive description: @@ -1383,10 +1472,10 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: b29a799ca03be9f999aa6c39f7de5209482d638e6f857f6b93b0875c618b7e54 + sha256: "76e4ab092c1b240d31177bb64d2b0bea43f43d0e23541ec866151b9f7b2490fa" url: "https://pub.dev" source: hosted - version: "12.0.7" + version: "12.0.12" permission_handler_apple: dependency: transitive description: @@ -1399,18 +1488,18 @@ packages: dependency: transitive description: name: permission_handler_html - sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.3+2" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.2.2" permission_handler_windows: dependency: transitive description: @@ -1423,10 +1512,10 @@ packages: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.2" pinenacl: dependency: "direct overridden" description: @@ -1519,10 +1608,10 @@ packages: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" ramp_flutter: dependency: "direct main" description: @@ -1551,34 +1640,34 @@ packages: dependency: "direct main" description: name: retrofit - sha256: "13a2865c0d97da580ea4e3c64d412d81f365fd5b26be2a18fca9582e021da37a" + sha256: "3c9885ef3dbc5dc4b3fb0a40c972ab52e4dad04d52dac9bba24dfa76cf100451" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.4.1" retrofit_generator: dependency: "direct dev" description: name: retrofit_generator - sha256: af46d19e82210850632e539b0d585dea8ed0c9a49b9ac6741306e19f8e83c90d + sha256: "40f166d6e07fc3c8f77700093767fd0c1b4742d27fe049ecb15cea1245284bd8" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.0.6" rive: dependency: "direct main" description: name: rive - sha256: "3c0047e636ebe8e4044087e239dffdd026cf839fe9aecf55d53431b255668bcf" + sha256: "468f0880d49c513e09fdfba26e4abd9d50433c2cf398210b62948d8de3837dd5" url: "https://pub.dev" source: hosted - version: "0.13.9" + version: "0.13.15" rive_common: dependency: transitive description: name: rive_common - sha256: "3fe76ba4680787741688ee393e47b63417e8643816795e4eac01021683af1d84" + sha256: a3e5786f8d85c89977062b9ceeb3b72a7c28f81e32fb68497744042ce20bee2f url: "https://pub.dev" source: hosted - version: "0.4.9" + version: "0.4.12" rxdart: dependency: "direct main" description: @@ -1615,18 +1704,18 @@ packages: dependency: transitive description: name: sentry - sha256: "7342ef4c18932881730ac941a07a6e4cf76fe99cd1ea3bef06e53a6a1402dec0" + sha256: "1af8308298977259430d118ab25be8e1dda626cdefa1e6ce869073d530d39271" url: "https://pub.dev" source: hosted - version: "8.3.0" + version: "8.8.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "475cf49682e4d1eb48caa2577502721bcfdcbb63f215de57b3b246d52f4f7914" + sha256: "18fe4d125c2d529bd6127200f0d2895768266a8c60b4fb50b2086fd97e1a4ab2" url: "https://pub.dev" source: hosted - version: "8.3.0" + version: "8.8.0" share: dependency: "direct main" description: @@ -1639,58 +1728,58 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.2" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.2" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" shelf: dependency: transitive description: @@ -1740,10 +1829,10 @@ packages: dependency: "direct main" description: name: smooth_page_indicator - sha256: "725bc638d5e79df0c84658e1291449996943f93bacbc2cec49963dbbab48d8ae" + sha256: "3b28b0c545fa67ed9e5997d9f9720d486f54c0c607e056a1094544e36934dff3" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0+3" solana: dependency: "direct main" description: @@ -1778,10 +1867,10 @@ packages: dependency: transitive description: name: source_map_stack_trace - sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" source_maps: dependency: transitive description: @@ -1826,10 +1915,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "6d17989c0b06a5870b2190d391925186f944cb943e5262d0d3f778fcfca3bc6e" + sha256: fde692580bee3379374af1f624eb3e113ab2865ecb161dbe2d8ac2de9735dbdb url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" sqlite3_flutter_libs: dependency: "direct main" description: @@ -1858,10 +1947,10 @@ packages: dependency: "direct main" description: name: stellar_flutter_sdk - sha256: "7d505963fe11d0f90b3f798964c485ed9fa64731c38f14c9b2fb76d5d5bd6cd8" + sha256: bb3194fca066590942c8395ff79d08c6b32d0cf3df5b4ebd32a9dd72340a0d9e url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.8.6" storybook_flutter: dependency: "direct main" description: @@ -1962,10 +2051,10 @@ packages: dependency: transitive description: name: toml - sha256: "69756bc12eccf279b72217a87310d217efc4b3752f722e890f672801f19ac485" + sha256: "9968de24e45b632bf1a654fe1ac7b6fe5261c349243df83fd262397799c45a2d" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.15.0" tuple: dependency: transitive description: @@ -2018,10 +2107,10 @@ packages: dependency: transitive description: name: unorm_dart - sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" + sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.0" url_launcher: dependency: "direct main" description: @@ -2034,26 +2123,26 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.9" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.0" url_launcher_macos: dependency: transitive description: @@ -2074,26 +2163,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" uuid: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.5.0" vector_graphics: dependency: transitive description: @@ -2178,10 +2267,10 @@ packages: dependency: transitive description: name: win32 - sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.5.4" xdg_directories: dependency: transitive description: @@ -2194,10 +2283,10 @@ packages: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" yaml: dependency: transitive description: @@ -2207,5 +2296,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.4.3 <4.0.0" flutter: ">=3.22.0" diff --git a/packages/espressocash_app/pubspec.yaml b/packages/espressocash_app/pubspec.yaml index ec7c5cc3ca..a7f3098608 100644 --- a/packages/espressocash_app/pubspec.yaml +++ b/packages/espressocash_app/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: country_flags: ^3.0.0 crypto: ^3.0.2 dart_jsonwebtoken: ^2.14.0 - decimal: ^2.3.3 + decimal: ^3.0.2 device_preview: ^1.1.0 dfunc: ^0.10.0 dio: ^5.4.0 @@ -27,6 +27,7 @@ dependencies: espressocash_api: path: ../espressocash_api/ ethereum_addresses: ^1.0.2 + face_camera: ^0.1.2 fast_immutable_collections: ^10.2.2 firebase_app_installations: ^0.3.0+2 firebase_core: ^3.1.1 @@ -50,6 +51,10 @@ dependencies: intercom_flutter: ^9.0.7 intl: ^0.19.0 json_annotation: ^4.8.1 + kyc_client_dart: + git: + url: https://github.com/espresso-cash/kyc_client_dart.git + ref: ca8414ca31a5995a0dc47e7fdcf4841826df315e logging: ^1.2.0 meta: ^1.10.0 mixpanel_flutter: ^2.2.0 @@ -76,7 +81,7 @@ dependencies: solana_mobile_wallet: path: ../solana_mobile_wallet/ sqlite3_flutter_libs: ^0.5.18 - stellar_flutter_sdk: ^1.8.0 + stellar_flutter_sdk: ^1.8.4 storybook_flutter: ^0.14.0 uni_links: ^0.5.1 url_launcher: ^6.2.2 @@ -168,6 +173,7 @@ flutter_native_splash: color: "#B4A270" dependency_overrides: + decimal: ^3.0.2 # TODO: Recheck once flutter_inappwebview version >6.0.0 is released flutter_inappwebview_android: git: diff --git a/packages/espressocash_app/test/golden/goldens/wallet_flow_screen.png b/packages/espressocash_app/test/golden/goldens/wallet_flow_screen.png index 0ac053046d..c5a2fe7853 100644 Binary files a/packages/espressocash_app/test/golden/goldens/wallet_flow_screen.png and b/packages/espressocash_app/test/golden/goldens/wallet_flow_screen.png differ diff --git a/packages/espressocash_app/test/golden/wrapper.dart b/packages/espressocash_app/test/golden/wrapper.dart index 6b6b871ea4..95fd2b3aec 100644 --- a/packages/espressocash_app/test/golden/wrapper.dart +++ b/packages/espressocash_app/test/golden/wrapper.dart @@ -11,7 +11,11 @@ class Wrapper extends StatelessWidget { @override Widget build(BuildContext context) { - ScreenUtil.init(context, designSize: const Size(428, 926)); + ScreenUtil.init( + context, + designSize: const Size(428, 926), + minTextAdapt: true, + ); return CpTheme.light( child: Builder(