diff --git a/.gitignore b/.gitignore index 0c7f51fb..b6da182d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ analysis_benchmark.json .packages.generated # Flutter/Dart/Pub related +**/lib/firebase_options.dart **/doc/api/ .dart_tool/ .flutter-plugins @@ -86,6 +87,7 @@ unlinked_spec.ds **/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Runner/GoogleService-Info.plist # macOS **/Flutter/ephemeral/ diff --git a/mobile-app/android/app/build.gradle b/mobile-app/android/app/build.gradle index c58ea49a..94e98a67 100644 --- a/mobile-app/android/app/build.gradle +++ b/mobile-app/android/app/build.gradle @@ -1,5 +1,8 @@ plugins { id "com.android.application" + // START: FlutterFire Configuration + id 'com.google.gms.google-services' + // END: FlutterFire Configuration id "kotlin-android" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" diff --git a/mobile-app/android/settings.gradle b/mobile-app/android/settings.gradle index 530045a2..044babb5 100644 --- a/mobile-app/android/settings.gradle +++ b/mobile-app/android/settings.gradle @@ -19,6 +19,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version '8.13.0' apply false + // START: FlutterFire Configuration + id "com.google.gms.google-services" version "4.3.15" apply false + // END: FlutterFire Configuration id "org.jetbrains.kotlin.android" version "2.2.0" apply false } diff --git a/mobile-app/assets/fonts/Inter-Bold.ttf b/mobile-app/assets/fonts/Inter-Bold.ttf new file mode 100644 index 00000000..a695b64c --- /dev/null +++ b/mobile-app/assets/fonts/Inter-Bold.ttf @@ -0,0 +1,1449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/mobile-app/assets/fonts/Inter-Light.ttf b/mobile-app/assets/fonts/Inter-Light.ttf new file mode 100644 index 00000000..d1c93a76 --- /dev/null +++ b/mobile-app/assets/fonts/Inter-Light.ttf @@ -0,0 +1,1449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/mobile-app/assets/fonts/Inter-Medium.ttf b/mobile-app/assets/fonts/Inter-Medium.ttf new file mode 100644 index 00000000..17eeef56 --- /dev/null +++ b/mobile-app/assets/fonts/Inter-Medium.ttf @@ -0,0 +1,1449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/mobile-app/assets/fonts/Inter-Regular.ttf b/mobile-app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 00000000..1e76fef2 --- /dev/null +++ b/mobile-app/assets/fonts/Inter-Regular.ttf @@ -0,0 +1,1449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/mobile-app/assets/fonts/Inter-SemiBold.ttf b/mobile-app/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 00000000..39a9000a --- /dev/null +++ b/mobile-app/assets/fonts/Inter-SemiBold.ttf @@ -0,0 +1,1449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/mobile-app/assets/v2/action_receive.svg b/mobile-app/assets/v2/action_receive.svg new file mode 100644 index 00000000..e59c9dc0 --- /dev/null +++ b/mobile-app/assets/v2/action_receive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile-app/assets/v2/action_send.svg b/mobile-app/assets/v2/action_send.svg new file mode 100644 index 00000000..b958acd5 --- /dev/null +++ b/mobile-app/assets/v2/action_send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/mobile-app/assets/v2/action_swap.svg b/mobile-app/assets/v2/action_swap.svg new file mode 100644 index 00000000..d4d7f033 --- /dev/null +++ b/mobile-app/assets/v2/action_swap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile-app/assets/v2/caret_left.svg b/mobile-app/assets/v2/caret_left.svg new file mode 100644 index 00000000..2f427be9 --- /dev/null +++ b/mobile-app/assets/v2/caret_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/mobile-app/assets/v2/glass_104_x_80.png b/mobile-app/assets/v2/glass_104_x_80.png new file mode 100644 index 00000000..07cff194 Binary files /dev/null and b/mobile-app/assets/v2/glass_104_x_80.png differ diff --git a/mobile-app/assets/v2/glass_40.png b/mobile-app/assets/v2/glass_40.png new file mode 100644 index 00000000..ea613694 Binary files /dev/null and b/mobile-app/assets/v2/glass_40.png differ diff --git a/mobile-app/assets/v2/glass_border_bg.png b/mobile-app/assets/v2/glass_border_bg.png new file mode 100644 index 00000000..d820670c Binary files /dev/null and b/mobile-app/assets/v2/glass_border_bg.png differ diff --git a/mobile-app/assets/v2/glass_button_wide_340_bg.png b/mobile-app/assets/v2/glass_button_wide_340_bg.png new file mode 100644 index 00000000..b4002a53 Binary files /dev/null and b/mobile-app/assets/v2/glass_button_wide_340_bg.png differ diff --git a/mobile-app/assets/v2/glass_circle_icon_button_bg.png b/mobile-app/assets/v2/glass_circle_icon_button_bg.png new file mode 100644 index 00000000..2cf38bea Binary files /dev/null and b/mobile-app/assets/v2/glass_circle_icon_button_bg.png differ diff --git a/mobile-app/assets/v2/glass_medium_button_bg.png b/mobile-app/assets/v2/glass_medium_button_bg.png new file mode 100644 index 00000000..32ed0c4a Binary files /dev/null and b/mobile-app/assets/v2/glass_medium_button_bg.png differ diff --git a/mobile-app/assets/v2/glass_medium_clear.png b/mobile-app/assets/v2/glass_medium_clear.png new file mode 100644 index 00000000..2d723f99 Binary files /dev/null and b/mobile-app/assets/v2/glass_medium_clear.png differ diff --git a/mobile-app/assets/v2/glass_medium_clear_small.png b/mobile-app/assets/v2/glass_medium_clear_small.png new file mode 100644 index 00000000..0f5c59ac Binary files /dev/null and b/mobile-app/assets/v2/glass_medium_clear_small.png differ diff --git a/mobile-app/assets/v2/green_checkmark.png b/mobile-app/assets/v2/green_checkmark.png new file mode 100644 index 00000000..1b1cb2e6 Binary files /dev/null and b/mobile-app/assets/v2/green_checkmark.png differ diff --git a/mobile-app/assets/v2/pending_send_box_arrow.png b/mobile-app/assets/v2/pending_send_box_arrow.png new file mode 100644 index 00000000..2d1de1a9 Binary files /dev/null and b/mobile-app/assets/v2/pending_send_box_arrow.png differ diff --git a/mobile-app/assets/v2/pin_number_background.png b/mobile-app/assets/v2/pin_number_background.png new file mode 100644 index 00000000..66b87a8c Binary files /dev/null and b/mobile-app/assets/v2/pin_number_background.png differ diff --git a/mobile-app/assets/v2/pin_number_background.svg b/mobile-app/assets/v2/pin_number_background.svg new file mode 100644 index 00000000..9b01077e --- /dev/null +++ b/mobile-app/assets/v2/pin_number_background.svg @@ -0,0 +1,8 @@ + +
+ + + + + +
diff --git a/mobile-app/assets/v2/quantus_white_logo.png b/mobile-app/assets/v2/quantus_white_logo.png new file mode 100644 index 00000000..2b65d5cf Binary files /dev/null and b/mobile-app/assets/v2/quantus_white_logo.png differ diff --git a/mobile-app/assets/v2/receive_button.png b/mobile-app/assets/v2/receive_button.png new file mode 100644 index 00000000..97223961 Binary files /dev/null and b/mobile-app/assets/v2/receive_button.png differ diff --git a/mobile-app/assets/v2/send_button.png b/mobile-app/assets/v2/send_button.png new file mode 100644 index 00000000..a612c22f Binary files /dev/null and b/mobile-app/assets/v2/send_button.png differ diff --git a/mobile-app/assets/v2/swap_arrows_down_up.svg b/mobile-app/assets/v2/swap_arrows_down_up.svg new file mode 100644 index 00000000..22a52430 --- /dev/null +++ b/mobile-app/assets/v2/swap_arrows_down_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile-app/assets/v2/swap_button.png b/mobile-app/assets/v2/swap_button.png new file mode 100644 index 00000000..ff9b7506 Binary files /dev/null and b/mobile-app/assets/v2/swap_button.png differ diff --git a/mobile-app/assets/v2/swap_clock_counter_clockwise.svg b/mobile-app/assets/v2/swap_clock_counter_clockwise.svg new file mode 100644 index 00000000..b1c5d03e --- /dev/null +++ b/mobile-app/assets/v2/swap_clock_counter_clockwise.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/swap_qr_code.svg b/mobile-app/assets/v2/swap_qr_code.svg new file mode 100644 index 00000000..0bbb9760 --- /dev/null +++ b/mobile-app/assets/v2/swap_qr_code.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/mobile-app/firebase.json b/mobile-app/firebase.json new file mode 100644 index 00000000..160015e6 --- /dev/null +++ b/mobile-app/firebase.json @@ -0,0 +1,30 @@ +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "quantus-wallet", + "appId": "1:700047185713:android:151f32080a837021d98210", + "fileOutput": "android/app/google-services.json" + } + }, + "ios": { + "default": { + "projectId": "quantus-wallet", + "appId": "1:700047185713:ios:4689e532e8a4f174d98210", + "uploadDebugSymbols": false, + "fileOutput": "ios/Runner/GoogleService-Info.plist" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "quantus-wallet", + "configurations": { + "android": "1:700047185713:android:151f32080a837021d98210", + "ios": "1:700047185713:ios:4689e532e8a4f174d98210" + } + } + } + } + } +} diff --git a/mobile-app/ios/Runner.xcodeproj/project.pbxproj b/mobile-app/ios/Runner.xcodeproj/project.pbxproj index 03bf29ee..55defffc 100644 --- a/mobile-app/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile-app/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; B271D7AD0A4C6686E7F60214 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB02A1061CC8E987BB71514 /* Pods_RunnerTests.framework */; }; + C2FAA8FDFD5122ED16B47C48 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 44914B43E735DCED6C9EBA96 /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,6 +51,7 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 44914B43E735DCED6C9EBA96 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 4672F8772DB9DA61003B0FFF /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 617F42319F855E3DC9D75E46 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -129,6 +131,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 697901FF368C5DA3A4CA46A0 /* Pods */, CEA34285B5A0D76CBF7D88A6 /* Frameworks */, + 44914B43E735DCED6C9EBA96 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -200,6 +203,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 2B5704DCBF3C1C1DEADD5FFB /* [CP] Embed Pods Frameworks */, + 6E98DD3DBFF3FA4BAA669C2E /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -265,6 +269,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + C2FAA8FDFD5122ED16B47C48 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -304,6 +309,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 6E98DD3DBFF3FA4BAA669C2E /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -478,6 +500,7 @@ DEVELOPMENT_TEAM = 8BRRAHLVW5; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -673,6 +696,7 @@ DEVELOPMENT_TEAM = 8BRRAHLVW5; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -705,6 +729,7 @@ DEVELOPMENT_TEAM = 8BRRAHLVW5; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/mobile-app/ios/Runner/AppDelegate.swift b/mobile-app/ios/Runner/AppDelegate.swift index bc815ab5..36cafd20 100644 --- a/mobile-app/ios/Runner/AppDelegate.swift +++ b/mobile-app/ios/Runner/AppDelegate.swift @@ -1,6 +1,7 @@ import Flutter import UIKit import flutter_local_notifications +import FirebaseMessaging @main @objc class AppDelegate: FlutterAppDelegate { @@ -12,11 +13,27 @@ import flutter_local_notifications GeneratedPluginRegistrant.register(with: registry) } + // Set the notification center delegate for flutter_local_notifications + // foreground presentation. Must happen before Firebase configures. if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } GeneratedPluginRegistrant.register(with: self) + + // Register for remote notifications (required when swizzling is disabled). + application.registerForRemoteNotifications() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + // With FirebaseAppDelegateProxyEnabled = NO, we must manually forward + // the APNs device token to Firebase Messaging. + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + } } diff --git a/mobile-app/ios/Runner/Info.plist b/mobile-app/ios/Runner/Info.plist index f43a992f..2c5ed3ae 100644 --- a/mobile-app/ios/Runner/Info.plist +++ b/mobile-app/ios/Runner/Info.plist @@ -1,63 +1,70 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Quantus - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Quantus - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - LSApplicationQueriesSchemes - - https - - FlutterDeepLinkingEnabled - - NSCameraUsageDescription - We need camera access to scan QR codes for sending funds. - NSFaceIDUsageDescription - Use Face ID to authenticate and securely access your wallet. - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Quantus + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Quantus + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + https + + LSRequiresIPhoneOS + + NSCameraUsageDescription + We need camera access to scan QR codes for sending funds. + NSFaceIDUsageDescription + Use Face ID to authenticate and securely access your wallet. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + fetch + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + FirebaseAppDelegateProxyEnabled + + diff --git a/mobile-app/lib/app_initializer.dart b/mobile-app/lib/app_initializer.dart index efcd9695..dd1fefce 100644 --- a/mobile-app/lib/app_initializer.dart +++ b/mobile-app/lib/app_initializer.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; import 'package:resonance_network_wallet/services/history_polling_manager.dart'; import 'package:resonance_network_wallet/services/local_notifications_service.dart'; +import 'package:resonance_network_wallet/utils/feature_flags.dart'; /// Widget that initializes the polling services for the entire app. /// This should be placed high in the widget tree, typically in your main app @@ -27,6 +29,11 @@ class _AppInitializerState extends ConsumerState { final notificationService = ref.read(localNotificationsServiceProvider); await notificationService.init(); + if (FeatureFlags.enableRemoteNotifications) { + final fcmService = ref.read(firebaseMessagingServiceProvider); + await fcmService.init(); + } + ref.read(historyPollingManagerProvider); } catch (e, stackTrace) { debugPrint('Initialization error: $e\n$stackTrace'); diff --git a/mobile-app/lib/features/main/screens/app.dart b/mobile-app/lib/features/main/screens/app.dart index 408ec839..58918cd0 100644 --- a/mobile-app/lib/features/main/screens/app.dart +++ b/mobile-app/lib/features/main/screens/app.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/features/main/screens/authentication_wrapper.dart'; import 'package:resonance_network_wallet/features/main/screens/wallet_initializer.dart'; -import 'package:resonance_network_wallet/features/styles/app_theme.dart'; +import 'package:resonance_network_wallet/utils/feature_flags.dart'; +import 'package:resonance_network_wallet/v2/theme/app_theme.dart'; +import 'package:resonance_network_wallet/services/firebase_messaging_service.dart'; import 'package:resonance_network_wallet/services/local_notifications_service.dart'; import 'package:resonance_network_wallet/services/notification_integration_service.dart'; import 'package:resonance_network_wallet/services/referral_service.dart'; @@ -32,6 +34,11 @@ class _ResonanceWalletAppState extends ConsumerState { ref.read(deepLinkServiceProvider).init(navigatorKey); ref.read(localNotificationsServiceProvider).setupNotificationsClickListener(navigatorKey); ref.read(localNotificationsServiceProvider).handleLaunchByNotification(navigatorKey); + + if (FeatureFlags.enableRemoteNotifications) { + ref.read(firebaseMessagingServiceProvider).setupNotificationTapHandlers(navigatorKey); + } + if (Platform.isAndroid) _referralService.checkPlayStoreReferralCode(); }); } @@ -55,7 +62,7 @@ class _ResonanceWalletAppState extends ConsumerState { '/account': (context) => const WalletInitializer(), '/transactions': (context) => const WalletInitializer(), }, - theme: AppTheme.lightTheme(context), + theme: AppTheme.darkTheme(context), darkTheme: AppTheme.darkTheme(context), themeMode: ThemeMode.dark, builder: (context, child) { diff --git a/mobile-app/lib/features/main/screens/navbar.dart b/mobile-app/lib/features/main/screens/navbar.dart index 1dc0ce2e..4e65250d 100644 --- a/mobile-app/lib/features/main/screens/navbar.dart +++ b/mobile-app/lib/features/main/screens/navbar.dart @@ -8,7 +8,7 @@ import 'package:resonance_network_wallet/features/components/referral_action_she import 'package:resonance_network_wallet/features/main/screens/quests/quests_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/settings_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/transactions_screen.dart'; -import 'package:resonance_network_wallet/features/main/screens/wallet_main/wallet_main.dart'; +import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/services/referral_service.dart'; @@ -214,7 +214,7 @@ class _NavbarState extends ConsumerState { return IndexedStack( index: _selectedIndex, children: [ - const WalletMain(), + const HomeScreen(), const TransactionsScreen(), const SettingsScreen(), QuestsScreen(key: _questsScreenKey), diff --git a/mobile-app/lib/features/main/screens/wallet_initializer.dart b/mobile-app/lib/features/main/screens/wallet_initializer.dart index 77d18ff7..9243f1cc 100644 --- a/mobile-app/lib/features/main/screens/wallet_initializer.dart +++ b/mobile-app/lib/features/main/screens/wallet_initializer.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/migration_dialog.dart'; -import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; -import 'package:resonance_network_wallet/features/main/screens/welcome_screen.dart'; +// import 'package:resonance_network_wallet/v2/screens/dev/button_test_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/welcome/welcome_screen.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; -import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/utils/env_utils.dart'; @@ -160,25 +160,21 @@ class WalletInitializerState extends ConsumerState { @override Widget build(BuildContext context) { - final hasTxIntent = ref.read(transactionIntentProvider) != null; - // If we have value of tx that means we got arguments from notification tap, - // so we wanted to display the transactions history screen instead which is index 1. - final initialIndex = hasTxIntent ? 1 : 0; - if (_loading) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } - // If migration is needed, render a neutral background (no spinner) while - // the bottom sheet is presented, to avoid a loading indicator behind it. if (_needsMigration) { return const Scaffold(body: SizedBox.shrink()); } + // for testing buttons + // return const ButtonTestScreen(); + if (_walletExists) { - return Navbar(initialIndex: initialIndex); + return const HomeScreen(); } else { - return const WelcomeScreen(); + return const WelcomeScreenV2(); } } } diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart index 5673b461..92339e0e 100644 --- a/mobile-app/lib/main.dart +++ b/mobile-app/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,8 +7,10 @@ import 'package:resonance_network_wallet/app_initializer.dart'; import 'package:resonance_network_wallet/app_lifecycle_manager.dart'; import 'package:resonance_network_wallet/features/main/screens/app.dart'; import 'package:resonance_network_wallet/utils/env_utils.dart'; +import 'package:resonance_network_wallet/utils/feature_flags.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:telemetrydecksdk/telemetrydecksdk.dart'; +import 'package:resonance_network_wallet/firebase_options.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -16,6 +19,10 @@ void main() async { // Initialize Supabase await Supabase.initialize(url: EnvUtils.supabaseUrl, anonKey: EnvUtils.supabaseKey); await QuantusSdk.init(); + if (FeatureFlags.enableRemoteNotifications) { + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + } + Telemetrydecksdk.start( const TelemetryManagerConfiguration( appID: '098B4397-8426-4054-B379-0E4C53D2CA63', diff --git a/mobile-app/lib/providers/notification_provider.dart b/mobile-app/lib/providers/notification_provider.dart index 209505e5..f0246057 100644 --- a/mobile-app/lib/providers/notification_provider.dart +++ b/mobile-app/lib/providers/notification_provider.dart @@ -177,10 +177,8 @@ class NotificationNotifier extends StateNotifier> { // No need to handle, because it already shown by default when we added to the state array. break; case NotificationSource.push: - _localNotificationsService.showOrScheduleNotification(notification); - break; case NotificationSource.remote: - // To be handled in the future + _localNotificationsService.showOrScheduleNotification(notification); break; } } @@ -305,12 +303,6 @@ class NotificationNotifier extends StateNotifier> { addNotification(notification); } - /// Stub for remote notifications (to be implemented later) - void addRemoteNotification(NotificationData notification) { - // This is a placeholder for future Firebase/APNs integration - addNotification(notification.copyWith(source: NotificationSource.remote)); - } - @override void dispose() { _cleanupTimer?.cancel(); diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index 541b2b65..4aac4d12 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -136,3 +136,19 @@ final highSecurityEstimatedFeeProvider = FutureProvider.family( ); return feeData.fee; }); + +final isBalanceHiddenProvider = StateNotifierProvider((ref) { + final settingsService = ref.watch(settingsServiceProvider); + return IsBalanceHiddenNotifier(settingsService); +}); + +class IsBalanceHiddenNotifier extends StateNotifier { + final SettingsService _settingsService; + + IsBalanceHiddenNotifier(this._settingsService) : super(_settingsService.isBalanceHidden()); + + Future setIsBalanceHidden(bool value) async { + await _settingsService.setBalanceHidden(value); + state = value; + } +} diff --git a/mobile-app/lib/services/firebase_messaging_service.dart b/mobile-app/lib/services/firebase_messaging_service.dart new file mode 100644 index 00000000..dd5682e5 --- /dev/null +++ b/mobile-app/lib/services/firebase_messaging_service.dart @@ -0,0 +1,190 @@ +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/models/notification_models.dart'; +import 'package:resonance_network_wallet/providers/notification_provider.dart'; +import 'package:resonance_network_wallet/services/transaction_service.dart'; + +/// Top-level handler for background/terminated FCM messages. +/// Must be a top-level function (not a class method) for Firebase. +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Background messages are automatically shown by the OS as notifications. + // No additional handling is needed here unless you want to persist data. + debugPrint('FCM background message: ${message.messageId}'); +} + +class FirebaseMessagingService { + final Ref _ref; + final FirebaseMessaging _messaging = FirebaseMessaging.instance; + final SenotiService _senotiService = SenotiService(); + + bool _isInitialized = false; + + FirebaseMessagingService(this._ref); + + /// Initialize FCM: request permissions, get token, and set up listeners. + Future init() async { + if (_isInitialized) return; + + final authorizationStatus = await _requestPermission(); + if (authorizationStatus != AuthorizationStatus.authorized) { + debugPrint('FCM permission not authorized'); + return; + } + + await _getToken(); + + _setupForegroundMessageListener(); + _setupTokenRefreshListener(); + _setupBackgroundMessageListener(); + + _isInitialized = true; + } + + /// Request notification permissions (required for iOS, Android 13+). + Future _requestPermission() async { + final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true, provisional: false); + + debugPrint('FCM permission status: ${settings.authorizationStatus}'); + + // On iOS, set foreground notification presentation options. + // This tells iOS to NOT show the system banner when the app is in the + // foreground, because we handle it ourselves via local notifications. + if (Platform.isIOS) { + await _messaging.setForegroundNotificationPresentationOptions(alert: false, badge: true, sound: false); + } + + return settings.authorizationStatus; + } + + Future _registerDevice(String token) async { + try { + await _senotiService.registerDevice(token, Platform.operatingSystem); + } catch (e) { + debugPrint('Failed to register device: $e'); + } + } + + /// Get the FCM device token (useful for server-side targeting). + Future _getToken() async { + final token = await _messaging.getToken(); + debugPrint('FCM token: $token'); + + if (token != null && token.isNotEmpty) { + await _registerDevice(token); + } + } + + /// Listen for token refresh events. + void _setupTokenRefreshListener() { + _messaging.onTokenRefresh.listen((newToken) async { + debugPrint('FCM token refreshed: $newToken'); + + await _registerDevice(newToken); + }); + } + + /// Listen for messages when the app is in the foreground. + /// FCM does NOT show a system notification in this case, so we convert + /// the message to a NotificationData and show it via local notifications. + void _setupForegroundMessageListener() { + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + debugPrint('FCM foreground message: ${message.messageId}'); + + final notification = _remoteMessageToNotificationData(message); + if (notification == null) return; + + // Add to the notification provider (persists + sends to stream). + final notifier = _ref.read(notificationProvider.notifier); + notifier.addNotification(notification); + }); + } + + void _setupBackgroundMessageListener() { + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + } + + /// Handle the user tapping on an FCM notification that launched/resumed the app. + /// Call this after the navigator key is available. + void setupNotificationTapHandlers(GlobalKey navigatorKey) { + // Handle tap when app was in background (not terminated). + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + debugPrint('FCM notification tapped (background): ${message.messageId}'); + _handleNotificationTap(message, navigatorKey); + }); + + // Handle tap when app was terminated. + _handleInitialMessage(navigatorKey); + } + + /// Check if the app was launched from a terminated state by tapping an FCM notification. + Future _handleInitialMessage(GlobalKey navigatorKey) async { + final initialMessage = await _messaging.getInitialMessage(); + if (initialMessage != null) { + debugPrint('FCM initial message (terminated): ${initialMessage.messageId}'); + _handleNotificationTap(initialMessage, navigatorKey); + } + } + + /// Navigate based on the FCM message data payload. + void _handleNotificationTap(RemoteMessage message, GlobalKey navigatorKey) { + final data = message.data; + if (data.isEmpty) return; + + final txService = _ref.read(transactionServiceProvider); + txService.navigateToTransactionFromPayloadIfPossible(data, navigatorKey); + } + + /// Convert an FCM [RemoteMessage] into the app's [NotificationData] model. + NotificationData? _remoteMessageToNotificationData(RemoteMessage message) { + final notification = message.notification; + final data = message.data; + + final title = notification?.title ?? data['title'] as String? ?? 'Notification'; + final body = notification?.body ?? data['body'] as String? ?? ''; + + // Parse optional fields from the data payload. + final accountId = data['accountId'] as String? ?? ''; + final accountName = data['accountName'] as String? ?? ''; + final typeStr = data['type'] as String?; + final intentStr = data['intent'] as String?; + + final type = NotificationType.values.firstWhere((e) => e.name == typeStr, orElse: () => NotificationType.info); + + final intent = NotificationIntent.values.firstWhere( + (e) => e.name == intentStr, + orElse: () => NotificationIntent.others, + ); + + // Build metadata from the data payload (excluding fields we already extracted). + final metadata = Map.from(data) + ..remove('title') + ..remove('body') + ..remove('accountId') + ..remove('accountName') + ..remove('type') + ..remove('intent'); + + return NotificationData( + id: 'remote_${message.messageId ?? DateTime.now().millisecondsSinceEpoch}', + accountId: accountId, + type: type, + intent: intent, + source: NotificationSource.remote, + title: title, + message: body, + accountName: accountName, + timestamp: DateTime.now(), + persistent: true, + metadata: metadata.isNotEmpty ? metadata : null, + ); + } +} + +final firebaseMessagingServiceProvider = Provider((ref) { + return FirebaseMessagingService(ref); +}); diff --git a/mobile-app/lib/services/local_notifications_service.dart b/mobile-app/lib/services/local_notifications_service.dart index 247e451d..6617aa21 100644 --- a/mobile-app/lib/services/local_notifications_service.dart +++ b/mobile-app/lib/services/local_notifications_service.dart @@ -5,7 +5,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/models/notification_models.dart'; -import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; import 'package:resonance_network_wallet/services/transaction_service.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest.dart' as tz; @@ -77,14 +76,10 @@ class LocalNotificationsService { final payload = notificationAppLaunchDetails!.notificationResponse?.payload; if (payload == null || payload.isEmpty) return; - final json = jsonDecode(payload); final txService = _ref.read(transactionServiceProvider); - final event = txService.deserializeTxEventFromJsonIfPossible(json); + final json = jsonDecode(payload); - if (event != null) { - _ref.read(transactionIntentProvider.notifier).state = event; - navigatorKey.currentState?.pushNamed('/transactions'); - } + txService.navigateToTransactionFromPayloadIfPossible(json, navigatorKey); } Future _showNotification(NotificationData notification) async { @@ -134,14 +129,10 @@ class LocalNotificationsService { _onNotificationClick.stream.listen((payload) { if (payload == null || payload.isEmpty) return; - final json = jsonDecode(payload); final txService = _ref.read(transactionServiceProvider); - final event = txService.deserializeTxEventFromJsonIfPossible(json); + final json = jsonDecode(payload); - if (event != null) { - _ref.read(transactionIntentProvider.notifier).state = event; - navigatorKey.currentState?.pushNamed('/transactions'); - } + txService.navigateToTransactionFromPayloadIfPossible(json, navigatorKey); }); } diff --git a/mobile-app/lib/services/transaction_service.dart b/mobile-app/lib/services/transaction_service.dart index d1e97dd7..3280ee9e 100644 --- a/mobile-app/lib/services/transaction_service.dart +++ b/mobile-app/lib/services/transaction_service.dart @@ -1,7 +1,9 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/models/transaction_role.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; final transactionServiceProvider = Provider((ref) { return TransactionService(ref); @@ -87,6 +89,15 @@ class TransactionService { } } + void navigateToTransactionFromPayloadIfPossible(Map? json, GlobalKey navigatorKey) { + final event = deserializeTxEventFromJsonIfPossible(json); + + if (event != null) { + _ref.read(transactionIntentProvider.notifier).state = event; + navigatorKey.currentState?.pushNamed('/transactions'); + } + } + TransactionEvent? deserializeTxEventFromJsonIfPossible(dynamic json) { final txType = json['type']; TransactionEvent? event; diff --git a/mobile-app/lib/utils/feature_flags.dart b/mobile-app/lib/utils/feature_flags.dart index a0241c15..02547118 100644 --- a/mobile-app/lib/utils/feature_flags.dart +++ b/mobile-app/lib/utils/feature_flags.dart @@ -3,4 +3,5 @@ class FeatureFlags { static const bool enableTestButtons = false; // Only show in debug mode static const bool enableKeystoneHardwareWallet = false; // turn keystone hw wallet on and off static const bool enableHighSecurity = true; // turn keystone hw wallet on and off + static const bool enableRemoteNotifications = false; // turn remote notifications on and off } diff --git a/mobile-app/lib/v2/components/back_button.dart b/mobile-app/lib/v2/components/back_button.dart new file mode 100644 index 00000000..09cafb2a --- /dev/null +++ b/mobile-app/lib/v2/components/back_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; + +class AppBackButton extends StatelessWidget { + final VoidCallback? onTap; + + const AppBackButton({super.key, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? () => Navigator.pop(context), + child: SvgPicture.asset( + 'assets/v2/caret_left.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode(context.colors.textPrimary, BlendMode.srcIn), + ), + ); + } +} diff --git a/mobile-app/lib/v2/components/bottom_sheet_container.dart b/mobile-app/lib/v2/components/bottom_sheet_container.dart new file mode 100644 index 00000000..b6ef6d80 --- /dev/null +++ b/mobile-app/lib/v2/components/bottom_sheet_container.dart @@ -0,0 +1,57 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class BottomSheetContainer extends StatelessWidget { + final String title; + final Widget child; + + const BottomSheetContainer({super.key, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Container( + padding: const EdgeInsets.fromLTRB(24, 40, 24, 40), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: BorderRadius.circular(24), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 32), + child, + ], + ), + ), + ); + } + + static void show(BuildContext context, {required WidgetBuilder builder}) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), + builder: (ctx) => BackdropFilter(filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), child: builder(ctx)), + ); + } +} diff --git a/mobile-app/lib/v2/components/glass_button.dart b/mobile-app/lib/v2/components/glass_button.dart new file mode 100644 index 00000000..c30437e9 --- /dev/null +++ b/mobile-app/lib/v2/components/glass_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; + +class GlassButton extends StatelessWidget { + final VoidCallback? onTap; + final Widget child; + final EdgeInsetsGeometry padding; + final double radius; + final bool filled; + final double height; + + const GlassButton({ + super.key, + this.onTap, + required this.child, + required this.height, + this.radius = 14, + this.padding = const EdgeInsets.symmetric(horizontal: 40, vertical: 20), + this.filled = false, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + final outerBorderColor = filled + ? const Color(0xFFFFFFFF).withValues(alpha: 0.32) + : const Color(0xFFFFFFFF).withValues(alpha: 0.44); + final innerBorderColor = filled + ? const Color(0xFFFFFFFF).withValues(alpha: 0.18) + : const Color(0xFFFFFFFF).withValues(alpha: 0.12); + + return GestureDetector( + onTap: onTap, + child: SizedBox( + height: height, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: filled ? const Color(0xFFFFFFFF).withValues(alpha: 0.1) : Colors.transparent, + ), + ), + ), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: outerBorderColor, width: 0.889), + ), + ), + ), + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(1), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius - 1), + border: Border.all(color: innerBorderColor, width: 0.6), + ), + ), + ), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.12), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.16), + ], + stops: const [0.0, 0.22, 0.78, 1.0], + ), + ), + ), + ), + Padding( + padding: padding, + child: Center(child: child), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/components/glass_circle_icon_button.dart b/mobile-app/lib/v2/components/glass_circle_icon_button.dart new file mode 100644 index 00000000..a91d4af7 --- /dev/null +++ b/mobile-app/lib/v2/components/glass_circle_icon_button.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class GlassCircleIconButton extends StatelessWidget { + static const _bgAsset = 'assets/v2/glass_circle_icon_button_bg.png'; + + final IconData icon; + final VoidCallback? onTap; + final double size; + final double iconSize; + final Color iconColor; + final bool filled; + + const GlassCircleIconButton({ + super.key, + required this.icon, + required this.iconColor, + this.onTap, + this.size = 48, + this.iconSize = 20, + this.filled = true, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: size, + height: size, + child: Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: filled ? 1 : 0.92, + child: Image.asset(_bgAsset, fit: BoxFit.cover), + ), + Center( + child: Icon(icon, color: iconColor, size: iconSize), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/components/glass_container.dart b/mobile-app/lib/v2/components/glass_container.dart new file mode 100644 index 00000000..9779fa48 --- /dev/null +++ b/mobile-app/lib/v2/components/glass_container.dart @@ -0,0 +1,128 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +class GlassContainer extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final String asset; + final GestureTapCallback? onTap; + final bool filled; + + static const mediumAsset = 'assets/v2/glass_medium_clear.png'; + static const mediumSmallAsset = 'assets/v2/glass_medium_clear_small.png'; // 36px height + static const smallAsset = 'assets/v2/glass_button_40_bg.png'; + static const wideAsset = 'assets/v2/glass_button_wide_340_bg.png'; + + static const _inset = 42.0; + static const _scale = 3.0; + static const _slices = { + mediumAsset: Rect.fromLTRB(_inset, _inset, 480 - _inset, 180 - _inset), + mediumSmallAsset: Rect.fromLTRB(_inset, _inset, 288 - _inset, 108 - _inset), + wideAsset: Rect.fromLTRB(_inset, _inset, 1020 - _inset, 168 - _inset), + }; + + double get defaultHeight => asset == smallAsset ? 40 : asset == mediumSmallAsset ? 36 : 56; + + const GlassContainer({ + super.key, + required this.child, + this.padding, + required this.asset, + this.filled = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final slice = _slices[asset]; + return GestureDetector( + onTap: onTap, + child: SizedBox( + height: defaultHeight, + child: Stack( + children: [ + Positioned.fill( + child: slice != null + ? _NineSliceImage(asset: asset, centerSlice: slice, scale: _scale) + : Image.asset(asset, fit: BoxFit.fill), + ), + if (filled) + Positioned.fill( + child: DecoratedBox(decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14))), + ), + Positioned.fill( + child: Padding( + padding: padding ?? EdgeInsets.zero, + child: Align(alignment: Alignment.center, child: child), + ), + ), + ], + ), + ), + ); + } +} + +class _NineSliceImage extends StatefulWidget { + final String asset; + final Rect centerSlice; + final double scale; + + const _NineSliceImage({required this.asset, required this.centerSlice, required this.scale}); + + @override + State<_NineSliceImage> createState() => _NineSliceImageState(); +} + +class _NineSliceImageState extends State<_NineSliceImage> { + ui.Image? _image; + late final _listener = ImageStreamListener((info, _) { + if (mounted) setState(() => _image = info.image); + }); + ImageStream? _stream; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _stream?.removeListener(_listener); + _stream = AssetImage(widget.asset).resolve(createLocalImageConfiguration(context)); + _stream!.addListener(_listener); + } + + @override + void dispose() { + _stream?.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_image == null) return const SizedBox.shrink(); + return CustomPaint(painter: _NineSlicePainter(_image!, widget.centerSlice, widget.scale)); + } +} + +class _NineSlicePainter extends CustomPainter { + final ui.Image image; + final Rect centerSlice; + final double scale; + + _NineSlicePainter(this.image, this.centerSlice, this.scale); + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(1 / scale, 1 / scale); + canvas.drawImageNine( + image, + centerSlice, + Rect.fromLTWH(0, 0, size.width * scale, size.height * scale), + Paint()..filterQuality = FilterQuality.low, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(_NineSlicePainter old) => image != old.image; +} diff --git a/mobile-app/lib/v2/components/gradient_background.dart b/mobile-app/lib/v2/components/gradient_background.dart new file mode 100644 index 00000000..ccb8f1e2 --- /dev/null +++ b/mobile-app/lib/v2/components/gradient_background.dart @@ -0,0 +1,81 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; + +class GradientBackground extends StatelessWidget { + final Widget child; + + const GradientBackground({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0.0, -0.487), + radius: 1.609, + colors: [colors.backgroundAlt, colors.background], + ), + ), + ), + ), + Positioned.fill( + child: CustomPaint(painter: _EllipseGlowPainter(glowColor: colors.backgroundGlow.useOpacity(0.3))), + ), + child, + ], + ); + } +} + +class _EllipseGlowPainter extends CustomPainter { + final Color glowColor; + + _EllipseGlowPainter({required this.glowColor}); + + @override + void paint(Canvas canvas, Size size) { + final sx = size.width / 390.0; + // final sy = size.height / 844.0; // we don't want to be relative on the y axis + final paint = Paint() + ..color = glowColor + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 100); + + const e1x = 95.0; + const e1y = 3.0; + const e1width = 86.0; + const e1height = 528.0; + const e2x = 330.0; + const e2y = -48.0; + const e2width = 44.0; + const e2height = 580.0; + + canvas.save(); + canvas.translate(e1x * sx, e1y); + canvas.rotate(30 * pi / 180); + canvas.drawOval(Rect.fromCenter(center: Offset.zero, width: e1width * sx, height: e1height), paint); + canvas.restore(); + + canvas.save(); + canvas.translate(e2x * sx, e2y); + canvas.rotate(30 * pi / 180); + canvas.drawOval(Rect.fromCenter(center: Offset.zero, width: e2width * sx, height: e2height), paint); + canvas.restore(); + + // DEBUG: vertical center lines + // final debugPaint = Paint() + // ..color = const Color(0xFFFF0000) + // ..strokeWidth = 1; + // canvas.drawLine(Offset(e1x * sx, 0), Offset(e1x * sx, size.height), debugPaint); + // debugPaint.color = const Color(0xFF00FF00); + // canvas.drawLine(Offset(e2x * sx, 0), Offset(e2x * sx, size.height), debugPaint); + } + + @override + bool shouldRepaint(_EllipseGlowPainter oldDelegate) => glowColor != oldDelegate.glowColor; +} diff --git a/mobile-app/lib/v2/components/success_check.dart b/mobile-app/lib/v2/components/success_check.dart new file mode 100644 index 00000000..08a1db1f --- /dev/null +++ b/mobile-app/lib/v2/components/success_check.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class SuccessCheck extends StatelessWidget { + final double size; + const SuccessCheck({super.key, this.size = 88}); + + @override + Widget build(BuildContext context) { + return Image.asset('assets/v2/green_checkmark.png', width: size, height: size); + } +} diff --git a/mobile-app/lib/v2/screens/activity/activity_screen.dart b/mobile-app/lib/v2/screens/activity/activity_screen.dart new file mode 100644 index 00000000..5e500c15 --- /dev/null +++ b/mobile-app/lib/v2/screens/activity/activity_screen.dart @@ -0,0 +1,144 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/transaction_service.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/tx_item.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/transaction_detail_sheet.dart'; + +class ActivityScreen extends ConsumerWidget { + const ActivityScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final text = context.themeText; + final accountAsync = ref.watch(activeAccountProvider); + final txAsync = ref.watch(activeAccountTransactionsProvider); + final isBalanceHidden = ref.watch(isBalanceHiddenProvider); + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Activity', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + Icon(Icons.info_outline, color: colors.textPrimary, size: 24), + ], + ), + const SizedBox(height: 48), + Expanded( + child: accountAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: colors.textPrimary)), + error: (e, _) => Center( + child: Text('Error: $e', style: text.detail?.copyWith(color: colors.textError)), + ), + data: (active) { + if (active == null) return const Center(child: Text('No account')); + return txAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: colors.textPrimary)), + error: (e, _) => Center( + child: Text('Error: $e', style: text.detail?.copyWith(color: colors.textError)), + ), + data: (data) { + final txService = ref.read(transactionServiceProvider); + final all = txService.combineAndDeduplicateTransactions( + pendingCancellationIds: data.pendingCancellationIds, + pendingTransactions: data.pendingTransactions, + reversibleTransfers: data.reversibleTransfers, + otherTransfers: data.otherTransfers, + ); + if (all.isEmpty) { + return Center( + child: Text( + 'No transactions yet', + style: text.paragraph?.copyWith(color: colors.textSecondary), + ), + ); + } + final grouped = _groupByDate(all); + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: grouped.length, + itemBuilder: (context, i) { + final group = grouped[i]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (i > 0) const SizedBox(height: 40), + Text( + group.label, + style: text.paragraph?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + ...group.transactions.mapIndexed((index, tx) { + final itemData = TxItemData.from(tx, active.account.accountId); + final isLastItem = index == group.transactions.length - 1; + return buildTxItem( + tx, + itemData, + colors, + text, + isBalanceHidden: isBalanceHidden, + isLastItem: isLastItem, + onTap: () { + showTransactionDetailSheet(context, tx, active.account.accountId); + }, + ); + }), + ], + ); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + List<_DateGroup> _groupByDate(List transactions) { + final Map> groups = {}; + final Map labelMap = {}; + + for (final tx in transactions) { + final day = DateTime(tx.timestamp.year, tx.timestamp.month, tx.timestamp.day); + final key = '${day.year}-${day.month}-${day.day}'; + groups.putIfAbsent(key, () => []); + groups[key]!.add(tx); + labelMap.putIfAbsent(key, () => dateGroupLabel(tx.timestamp)); + } + + return groups.entries.map((e) => _DateGroup(label: labelMap[e.key]!, transactions: e.value)).toList(); + } +} + +class _DateGroup { + final String label; + final List transactions; + const _DateGroup({required this.label, required this.transactions}); +} diff --git a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart new file mode 100644 index 00000000..f02e9349 --- /dev/null +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/shared/extensions/transaction_event_extension.dart'; +import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:url_launcher/url_launcher.dart'; + +void showTransactionDetailSheet(BuildContext context, TransactionEvent tx, String activeAccountId) { + BottomSheetContainer.show( + context, + builder: (_) => _TransactionDetailSheet(tx: tx, activeAccountId: activeAccountId), + ); +} + +class _TransactionDetailSheet extends StatefulWidget { + final TransactionEvent tx; + final String activeAccountId; + const _TransactionDetailSheet({required this.tx, required this.activeAccountId}); + + @override + State<_TransactionDetailSheet> createState() => _TransactionDetailSheetState(); +} + +class _TransactionDetailSheetState extends State<_TransactionDetailSheet> { + final _checksumService = HumanReadableChecksumService(); + String? _checkphrase; + + bool get _isSend => widget.tx.from == widget.activeAccountId; + String get _counterparty => _isSend ? widget.tx.to : widget.tx.from; + + String get _title { + if (widget.tx.isReversibleScheduled) return _isSend ? 'Pending' : 'Receiving'; + return _isSend ? 'Sent' : 'Received'; + } + + @override + void initState() { + super.initState(); + _checksumService.getHumanReadableName(_counterparty).then((name) { + if (mounted) setState(() => _checkphrase = name); + }); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return BottomSheetContainer( + title: _title, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 40), + _amountCard(colors, text), + const SizedBox(height: 40), + _addressSection(colors, text), + _feeRow(colors, text), + const SizedBox(height: 32), + _explorerButton(colors, text), + ], + ), + ); + } + + Widget _amountCard(AppColorsV2 colors, AppTextTheme text) { + final fmt = NumberFormattingService(); + final amount = fmt.formatBalance(widget.tx.amount); + final date = DateFormat('MMM d, yyyy').format(widget.tx.timestamp); + final time = DateFormat('h:mm a').format(widget.tx.timestamp); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$amount ${AppConstants.tokenSymbol}', + style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 32, fontWeight: FontWeight.w600), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + date, + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Text('At $time', style: text.detail?.copyWith(color: Colors.white.withValues(alpha: 0.5))), + ], + ), + ], + ), + ); + } + + Widget _addressSection(AppColorsV2 colors, AppTextTheme text) { + final direction = _isSend ? 'To:' : 'From:'; + final address = AddressFormattingService.formatAddress(_counterparty, prefix: 15, ellipses: '.......', postFix: 14); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + direction, + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + address, + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _copyButton(colors, _counterparty), + ], + ), + if (_checkphrase != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text(_checkphrase!, style: text.smallParagraph?.copyWith(color: colors.accentPink)), + ), + const SizedBox(width: 8), + _copyButton(colors, _checkphrase!), + ], + ), + ], + const SizedBox(height: 24), + ], + ); + } + + Widget _copyButton(AppColorsV2 colors, String value) { + return GestureDetector( + onTap: () => Clipboard.setData(ClipboardData(text: value)), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)), + child: const Icon(Icons.copy, size: 12, color: Colors.white), + ), + ); + } + + Widget _feeRow(AppColorsV2 colors, AppTextTheme text) { + BigInt? fee; + if (widget.tx is TransferEvent) fee = (widget.tx as TransferEvent).fee; + if (widget.tx is PendingTransactionEvent) fee = (widget.tx as PendingTransactionEvent).fee; + if (fee == null || fee == BigInt.zero) return const SizedBox.shrink(); + final fmt = NumberFormattingService(); + final feeStr = '${fmt.formatBalance(fee)} ${AppConstants.tokenSymbol}'; + final style = text.detail?.copyWith(color: Colors.white.withValues(alpha: 0.5)); + + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Network Fee:', style: style), + Text(feeStr, style: style), + ], + ), + ); + } + + Widget _explorerButton(AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: _openExplorer, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.44)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'View in Explorer', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 8), + Icon(Icons.open_in_new, size: 16, color: colors.textPrimary), + ], + ), + ), + ); + } + + void _openExplorer() { + final tx = widget.tx; + final isMinerReward = tx.isMinerReward; + final transactionType = isMinerReward + ? 'miner-rewards' + : (tx.isReversibleScheduled || tx.isReversibleExecuted || tx.isReversibleCancelled) + ? 'reversible-transactions' + : 'immediate-transactions'; + + String? path; + if (tx.extrinsicHash != null) { + path = '$transactionType/${tx.extrinsicHash}'; + } else if (isMinerReward && tx.blockHash != null) { + path = '$transactionType/${tx.blockHash}'; + } + if (path != null) launchUrl(Uri.parse('${AppConstants.explorerEndpoint}/$path')); + } +} diff --git a/mobile-app/lib/v2/screens/activity/tx_item.dart b/mobile-app/lib/v2/screens/activity/tx_item.dart new file mode 100644 index 00000000..4bf1ed79 --- /dev/null +++ b/mobile-app/lib/v2/screens/activity/tx_item.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/shared/extensions/transaction_event_extension.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class TxItemData { + final String label; + final String timeLabel; + final Color iconBg; + final Color iconColor; + final bool isSend; + final String amount; + final String counterpartyAddr; + + const TxItemData({ + required this.label, + required this.timeLabel, + required this.iconBg, + required this.iconColor, + required this.isSend, + required this.amount, + required this.counterpartyAddr, + }); + + factory TxItemData.from(TransactionEvent tx, String accountId) { + final isSend = tx.from == accountId; + final isScheduled = tx.isReversibleScheduled; + final fmt = NumberFormattingService(); + + return TxItemData( + label: isScheduled + ? (isSend ? 'Pending' : 'Receiving') + : isSend + ? 'Sent' + : 'Received', + timeLabel: isScheduled ? _formatDuration(tx.timeRemaining) : _timeAgo(tx.timestamp), + iconBg: isScheduled && !isSend + ? const Color(0x2927F027) + : isScheduled && isSend + ? const Color(0x29FFBC42) + : const Color(0xFF292929), + iconColor: isScheduled && !isSend + ? const Color(0xFF27F027) + : isScheduled && isSend + ? const Color(0xFFFFBC42) + : const Color(0x80FFFFFF), + isSend: isSend, + amount: '${fmt.formatBalance(tx.amount)} ${AppConstants.tokenSymbol}', + counterpartyAddr: _shortenAddress(isSend ? tx.to : tx.from), + ); + } +} + +Widget buildTxItem( + TransactionEvent tx, + TxItemData data, + AppColorsV2 colors, + AppTextTheme text, { + required bool isBalanceHidden, + required bool isLastItem, + VoidCallback? onTap, +}) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: data.iconBg, borderRadius: BorderRadius.circular(6)), + child: Transform.rotate( + angle: data.isSend ? 3.14159 : 0, + child: Icon(Icons.arrow_downward_rounded, size: 16, color: data.iconColor), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.label, style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 2), + Text(data.timeLabel, style: text.detail?.copyWith(color: colors.textTertiary)), + ], + ), + ), + if (!isBalanceHidden) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(data.amount, style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 2), + Text( + '${data.isSend ? "To" : "From"}: ${data.counterpartyAddr}', + style: text.detail?.copyWith(color: colors.textTertiary), + ), + ], + ) + else + Center( + child: Text('--------', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + ), + ], + ), + ), + if (!isLastItem) Divider(color: colors.txItemSeparator, height: 1), + ], + ), + ); +} + +String _shortenAddress(String addr) { + if (addr.length <= 10) return addr; + return '${addr.substring(0, 5)}...${addr.substring(addr.length - 3)}'; +} + +String _formatDuration(Duration d) { + final days = d.inDays; + final hours = d.inHours % 24; + final mins = d.inMinutes % 60; + return '${days.toString().padLeft(2, '0')}d:${hours.toString().padLeft(2, '0')}h:${mins.toString().padLeft(2, '0')}m'; +} + +String _timeAgo(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; +} + +String dateGroupLabel(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final txDay = DateTime(date.year, date.month, date.day); + final diff = today.difference(txDay).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} diff --git a/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart b/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart new file mode 100644 index 00000000..43aaafc0 --- /dev/null +++ b/mobile-app/lib/v2/screens/create/wallet_ready_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/snackbar_helper.dart'; +import 'package:resonance_network_wallet/features/main/screens/create_wallet_and_backup_screen.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/services/referral_service.dart'; +import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class WalletReadyScreenV2 extends ConsumerStatefulWidget { + const WalletReadyScreenV2({super.key, this.walletIndex = 0}); + + final int walletIndex; + + @override + ConsumerState createState() => _WalletReadyScreenV2State(); +} + +class _WalletReadyScreenV2State extends ConsumerState { + String _mnemonic = ''; + bool _isLoading = true; + bool _isSubmitting = false; + String? _error; + + final SettingsService _settingsService = SettingsService(); + final AccountsService _accountsService = AccountsService(); + final HdWalletService _hdWalletService = HdWalletService(); + final ReferralService _referralService = ReferralService(); + + final _accountName = TextEditingController(); + String? _accountNameError; + + late String _address; + late String _checksum; + + @override + void initState() { + super.initState(); + _accountName.text = 'Account 1'; + _generateMnemonic(); + } + + Future _generateMnemonic() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + _mnemonic = await SubstrateService().generateMnemonic(); + if (_mnemonic.isEmpty) throw Exception('Mnemonic generation returned empty.'); + + _address = _hdWalletService.keyPairAtIndex(_mnemonic, 0).ss58Address; + _checksum = await HumanReadableChecksumService().getHumanReadableName(_address); + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _error = 'Failed to generate: $e'; + }); + } + } + } + + Future _continue() async { + if (_mnemonic.isEmpty || _accountNameError != null) return; + + setState(() => _isSubmitting = true); + try { + await _settingsService.setMnemonic(_mnemonic, widget.walletIndex); + + final accounts = ref.read(accountsProvider).value ?? []; + final hasRoot = accounts.any((a) => a.walletIndex == widget.walletIndex && a.index == 0); + if (!hasRoot) { + await _accountsService.addAccount( + Account(walletIndex: widget.walletIndex, index: 0, name: _accountName.text.trim(), accountId: _address), + ); + try { + _referralService.submitAddressToBackend(); + } catch (_) {} + } + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + + if (!mounted) return; + Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const HomeScreen()), (route) => false); + } catch (e) { + if (mounted) showCopySnackbar(context, title: 'Error', message: 'Error saving wallet: $e'); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final canContinue = !_isLoading && _error == null && _accountNameError == null; + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text( + 'Your Wallet Is Ready', + style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 24), + ), + ], + ), + const SizedBox(height: 40), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Field( + label: 'Wallet Name', + value: _accountName.text, + isLoading: _isLoading, + actionIcon: Icons.edit, + onAction: () => _showEditNameSheet(colors, text), + ), + const SizedBox(height: 24), + _Field( + label: 'Wallet Address', + value: _isLoading + ? '...' + : AddressFormattingService.formatAddress( + _address, + prefix: 15, + ellipses: '.......', + postFix: 14, + ), + isLoading: _isLoading, + actionIcon: Icons.copy, + onAction: () => ClipboardExtensions.copyTextWithSnackbar(context, _address), + ), + const SizedBox(height: 24), + _Field( + label: 'Wallet Checkphrase', + value: _isLoading ? '...' : _checksum, + isLoading: _isLoading, + valueColor: colors.accentPink, + actionIcon: Icons.copy, + onAction: () => ClipboardExtensions.copyTextWithSnackbar( + context, + _checksum, + message: 'Checkphrase copied', + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () { + final words = _mnemonic.isNotEmpty ? _mnemonic.split(' ') : []; + showRecoveryPhraseSheet(context, words, _isLoading, _error, _mnemonic); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.visibility_outlined, size: 16, color: colors.textSecondary), + const SizedBox(width: 8), + Text('View recovery phrase', style: text.detail?.copyWith(color: colors.textSecondary)), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + const SizedBox(height: 24), + GlassContainer( + asset: GlassContainer.wideAsset, + onTap: canContinue ? _continue : null, + child: _isSubmitting + ? Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: colors.textPrimary), + ), + ) + : Center( + child: Text( + 'Continue', + style: text.paragraph?.copyWith(fontWeight: FontWeight.w500, color: colors.textPrimary), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + void _showEditNameSheet(AppColorsV2 colors, AppTextTheme text) { + final controller = TextEditingController(text: _accountName.text); + showModalBottomSheet( + context: context, + backgroundColor: colors.background, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(24))), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Wallet Name', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 12), + TextField( + controller: controller, + style: text.paragraph?.copyWith(color: colors.textPrimary), + decoration: InputDecoration( + filled: true, + fillColor: colors.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + const SizedBox(height: 24), + GlassContainer( + asset: GlassContainer.wideAsset, + filled: true, + onTap: () async { + final v = controller.text.trim(); + if (v.isNotEmpty) { + setState(() { + _accountName.text = v; + _accountNameError = null; + }); + Navigator.pop(ctx); + } + }, + child: Center( + child: Text( + 'Save', + style: text.paragraph?.copyWith(fontWeight: FontWeight.w500, color: colors.textPrimary), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Field extends StatelessWidget { + final String label; + final String value; + final bool isLoading; + final Color? valueColor; + final IconData actionIcon; + final VoidCallback onAction; + + const _Field({ + required this.label, + required this.value, + required this.isLoading, + this.valueColor, + required this.actionIcon, + required this.onAction, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 8), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8)), + child: Row( + children: [ + Expanded( + child: Text( + value, + style: text.smallParagraph?.copyWith(color: valueColor ?? colors.textPrimary), + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox( + width: 40, + height: 40, + child: GlassContainer( + asset: GlassContainer.smallAsset, + filled: true, + padding: EdgeInsets.zero, + onTap: isLoading + ? null + : () async { + onAction(); + }, + child: Icon(actionIcon, size: 20, color: colors.textPrimary), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/v2/screens/dev/button_test_screen.dart b/mobile-app/lib/v2/screens/dev/button_test_screen.dart new file mode 100644 index 00000000..8f309919 --- /dev/null +++ b/mobile-app/lib/v2/screens/dev/button_test_screen.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:glass_kit/glass_kit.dart' as gk; +import 'package:resonance_network_wallet/v2/components/glass_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class ButtonTestScreen extends StatelessWidget { + const ButtonTestScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: const Color(0xFF1A1A1A), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Button Test', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(height: 32), + + _label('Our GlassButton (outline)', colors, text), + GlassButton( + height: 56, + onTap: () {}, + child: Center( + child: Text( + 'Outline (Clear Outline)', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 16), + + _label('Our GlassButton (filled - Clear Glass)', colors, text), + GlassButton( + height: 56, + filled: true, + onTap: () {}, + child: Center( + child: Text( + 'Filled (Clear Glass)', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 16), + + _label('Our GlassButton (disabled 20%)', colors, text), + Opacity( + opacity: 0.2, + child: GlassButton( + height: 56, + child: Center( + child: Text( + 'Disabled', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + ), + const SizedBox(height: 32), + + // _label('glass_kit frostedGlass', colors, text), + // gk.GlassContainer.frostedGlass( + // height: 56, + // borderRadius: BorderRadius.circular(14), + // gradient: LinearGradient(colors: [Colors.white.withValues(alpha: 0.1), Colors.white.withValues(alpha: 0.05)]), + // borderGradient: const LinearGradient( + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // colors: [Color(0x55FFFFFF), Color(0x18FFFFFF)], + // ), + // blur: 20, + // frostedOpacity: 0.1, + // child: Center(child: Text('Frosted Glass', style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500))), + // ), + // const SizedBox(height: 16), + + // _label('glass_kit clearGlass (no fill)', colors, text), + // gk.GlassContainer.clearGlass( + // height: 56, + // borderRadius: BorderRadius.circular(14), + // gradient: const LinearGradient(colors: [Colors.transparent, Colors.transparent]), + // borderGradient: const LinearGradient( + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // colors: [Color(0x70FFFFFF), Color(0x18FFFFFF)], + // ), + // borderWidth: 0.889, + // blur: 20, + // child: Center(child: Text('Clear Outline Only', style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500))), + // ), + // const SizedBox(height: 32), + _label('Plain', colors, text), + gk.GlassContainer.clearGlass( + height: 56, + borderRadius: BorderRadius.circular(14), + color: const Color(0xFFFFFFFF).withValues(alpha: 0.1), + // there is no gradient fill in our design. + // gradient: LinearGradient(colors: [Colors.white.withValues(alpha: 0.08), Colors.white.withValues(alpha: 0.04)]), + borderGradient: LinearGradient( + colors: [ + const Color(0xFFFFFFFF).withValues(alpha: 0.66), + const Color(0xFFFFFFFF).withValues(alpha: 0.66), + ], + ), + borderWidth: 0.889, + blur: 20, + child: Center( + child: Text( + 'Plain', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 16), + + _label('+ inner shadow (top+bottom)', colors, text), + gk.GlassContainer.clearGlass( + height: 56, + borderRadius: BorderRadius.circular(14), + color: const Color(0xFFFFFFFF).withValues(alpha: 0.1), + borderGradient: LinearGradient( + colors: [ + const Color(0xFFFFFFFF).withValues(alpha: 0.66), + const Color(0xFFFFFFFF).withValues(alpha: 0.66), + ], + ), + borderWidth: 0.889, + blur: 20, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.2), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.15), + ], + stops: const [0.0, 0.25, 0.75, 1.0], + ), + ), + ), + ), + Center( + child: Text( + 'Top+Bottom', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + _label('Our GlassButton (filled - Clear Glass)', colors, text), + GlassButton( + height: 56, + filled: true, + onTap: () {}, + child: Center( + child: Text( + 'Current', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 16), + + _label('TARGET: PNG wide', colors, text), + GlassContainer( + asset: GlassContainer.wideAsset, + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + child: Center( + child: Text( + 'Wide PNG', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 16), + + _label('PNG: glass_button_40_bg (small)', colors, text), + const Row( + children: [ + SizedBox( + width: 40, + height: 40, + child: GlassContainer( + asset: GlassContainer.smallAsset, + child: Center(child: Icon(Icons.edit, size: 18, color: Colors.white)), + ), + ), + SizedBox(width: 12), + SizedBox( + width: 40, + height: 40, + child: GlassContainer( + asset: GlassContainer.smallAsset, + child: Center(child: Icon(Icons.copy, size: 18, color: Colors.white)), + ), + ), + ], + ), + const SizedBox(height: 16), + + // _label('PNG: glass_border_bg', colors, text), + // SizedBox( + // height: 56, + // child: GlassContainer( + // asset: 'assets/v2/glass_border_bg.png', + // padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + // child: Center(child: Text('Border PNG', style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500))), + // ), + // ), + // const SizedBox(height: 32), + _label('Action card PNGs', colors, text), + Row( + children: [ + Expanded(child: Image.asset('assets/v2/receive_button.png')), + const SizedBox(width: 15), + Expanded(child: Image.asset('assets/v2/send_button.png')), + const SizedBox(width: 15), + Expanded(child: Image.asset('assets/v2/swap_button.png')), + ], + ), + const SizedBox(height: 60), + ], + ), + ), + ), + ); + } + + Widget _label(String s, AppColorsV2 colors, AppTextTheme text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(s, style: text.detail?.copyWith(color: colors.textSecondary)), + ); + } +} diff --git a/mobile-app/lib/v2/screens/home/activity_section.dart b/mobile-app/lib/v2/screens/home/activity_section.dart new file mode 100644 index 00000000..2d0d9943 --- /dev/null +++ b/mobile-app/lib/v2/screens/home/activity_section.dart @@ -0,0 +1,151 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/skeleton.dart'; +import 'package:resonance_network_wallet/models/combined_transactions_list.dart'; +import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/transaction_service.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/activity_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/transaction_detail_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/activity/tx_item.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class ActivitySection extends ConsumerWidget { + final AsyncValue txAsync; + final BaseAccount activeAccount; + final Future Function()? onRetry; + + const ActivitySection({super.key, required this.txAsync, required this.activeAccount, this.onRetry}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBalanceHidden = ref.watch(isBalanceHiddenProvider); + final colors = context.colors; + final text = context.themeText; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: txAsync.when( + data: (data) { + final txService = ref.read(transactionServiceProvider); + final all = txService.combineAndDeduplicateTransactions( + pendingCancellationIds: data.pendingCancellationIds, + pendingTransactions: data.pendingTransactions, + reversibleTransfers: data.reversibleTransfers, + otherTransfers: data.otherTransfers, + ); + final recentTransactions = all.take(5).toList(); + + if (all.isEmpty) { + return Column( + children: [ + const SizedBox(height: 40), + _header(colors, text, context), + const SizedBox(height: 48), + Icon(Icons.receipt_long_outlined, size: 48, color: colors.textTertiary), + const SizedBox(height: 16), + Text('No transactions yet', style: text.paragraph?.copyWith(color: colors.textSecondary)), + const SizedBox(height: 8), + Text('Your activity will appear here', style: text.detail?.copyWith(color: colors.textTertiary)), + ], + ); + } + + return Column( + children: [ + const SizedBox(height: 40), + _header(colors, text, context), + const SizedBox(height: 24), + + ...recentTransactions.mapIndexed((index, tx) { + final data = TxItemData.from(tx, activeAccount.accountId); + final isLastItem = index == recentTransactions.length - 1; + + return buildTxItem( + tx, + data, + colors, + text, + isBalanceHidden: isBalanceHidden, + isLastItem: isLastItem, + onTap: () { + showTransactionDetailSheet(context, tx, activeAccount.accountId); + }, + ); + }), + ], + ); + }, + loading: () => Padding( + padding: const EdgeInsets.only(top: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(colors, text, context), + const SizedBox(height: 24), + for (var i = 0; i < 3; i++) ...[ + const Skeleton(width: double.infinity, height: 32), + if (i < 2) Divider(color: colors.txItemSeparator, height: 24), + ], + ], + ), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.only(top: 40), + child: Column( + children: [ + Text('Error loading transactions', style: text.detail?.copyWith(color: colors.textError)), + const SizedBox(height: 12), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + ref.invalidate(activeAccountTransactionsProvider); + onRetry?.call(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + 'Retry', + style: text.smallParagraph?.copyWith( + color: colors.textPrimary, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _header(AppColorsV2 colors, AppTextTheme text, BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Activity', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ActivityScreen())), + child: Text( + 'View All', + style: text.paragraph?.copyWith( + color: Colors.transparent, + shadows: [Shadow(color: colors.textSecondary, offset: const Offset(0, -2))], // Shadow trick to create gap between text and underline + decoration: TextDecoration.underline, + decorationColor: colors.textSecondary, + decorationStyle: TextDecorationStyle.solid, + decorationThickness: 1.0, + ), + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart new file mode 100644 index 00000000..66ddd0b2 --- /dev/null +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -0,0 +1,287 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; +import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; +import 'package:resonance_network_wallet/features/components/skeleton.dart'; +import 'package:resonance_network_wallet/features/main/screens/accounts_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/receive/receive_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/settings_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; +import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/filtered_all_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/components/glass_circle_icon_button.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:resonance_network_wallet/v2/screens/home/activity_section.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + static const _actionButtonBgAsset = 'assets/v2/glass_104_x_80.png'; + + final NumberFormattingService _fmt = NumberFormattingService(); + + Future _refresh() async { + final active = ref.read(activeAccountProvider).value; + if (active != null) { + ref.invalidate(balanceProviderFamily); + await ref + .read(filteredPaginationControllerProviderFamily(AccountIdListCache.get([active.account.accountId])).notifier) + .loadingRefresh(); + } + ref.invalidate(balanceProviderRaw); + ref.invalidate(activeAccountTransactionsProvider); + } + + void _processIntentIfAvailable() { + final shared = ref.read(sharedAccountIntentProvider); + if (shared != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(sharedAccountIntentProvider.notifier).state = null; + showSharedAddressActionSheet(context, shared); + }); + } + } + + Future toggleBalanceHidden(bool isBalanceHidden) async { + final isBalanceHiddenNotifier = ref.read(isBalanceHiddenProvider.notifier); + await isBalanceHiddenNotifier.setIsBalanceHidden(!isBalanceHidden); + } + + @override + Widget build(BuildContext context) { + _processIntentIfAvailable(); + + final isBalanceHidden = ref.watch(isBalanceHiddenProvider); + final accountAsync = ref.watch(activeAccountProvider); + final balanceAsync = ref.watch(balanceProvider); + final txAsync = ref.watch(activeAccountTransactionsProvider); + final colors = context.colors; + final text = context.themeText; + + return accountAsync.when( + loading: () => Scaffold( + backgroundColor: colors.background, + body: Center(child: CircularProgressIndicator(color: colors.textPrimary)), + ), + error: (e, _) => Scaffold( + backgroundColor: colors.background, + body: Center( + child: Text('Error: $e', style: text.detail?.copyWith(color: colors.textError)), + ), + ), + data: (active) { + if (active == null) { + return Scaffold( + backgroundColor: colors.background, + body: const Center(child: Text('No active account')), + ); + } + return Scaffold( + backgroundColor: colors.background, + body: RefreshIndicator( + color: colors.textPrimary, + backgroundColor: colors.surface, + onRefresh: _refresh, + child: GradientBackground( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _buildContent(active, balanceAsync, isBalanceHidden, colors, text)), + SliverToBoxAdapter( + child: ActivitySection(txAsync: txAsync, activeAccount: active.account, onRetry: _refresh), + ), + const SliverToBoxAdapter(child: SizedBox(height: 58)), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildContent( + DisplayAccount active, + AsyncValue balanceAsync, + bool isBalanceHidden, + AppColorsV2 colors, + AppTextTheme text, + ) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + _buildTopBar(active, isBalanceHidden, colors), + const SizedBox(height: 64), + _buildBalance(balanceAsync, isBalanceHidden, colors, text), + const SizedBox(height: 64), + if (active is RegularAccount) _buildActionButtons(colors, text), + ], + ), + ), + ); + } + + Widget _buildTopBar(DisplayAccount active, bool isBalanceHidden, AppColorsV2 colors) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen())), + child: AccountGradientImage(accountId: active.account.accountId, width: 40.0, height: 40.0), + ), + Row( + children: [ + _glassCircleButton( + icon: isBalanceHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, + colors: colors, + onTap: () => toggleBalanceHidden(isBalanceHidden), + ), + const SizedBox(width: 12), + _glassCircleButton( + icon: Icons.settings_outlined, + colors: colors, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreenV2())), + ), + ], + ), + ], + ); + } + + Widget _glassCircleButton({required IconData icon, required AppColorsV2 colors, required VoidCallback onTap}) { + return GlassCircleIconButton(icon: icon, iconColor: colors.textPrimary, onTap: onTap, size: 40, iconSize: 20); + } + + Widget _buildBalance(AsyncValue balanceAsync, bool isBalanceHidden, AppColorsV2 colors, AppTextTheme text) { + const stableHeight = 96.0; + + return SizedBox( + height: stableHeight, + child: Column( + children: [ + balanceAsync.when( + data: (balance) { + final formatted = isBalanceHidden ? '-----' : _fmt.formatBalance(balance); + return Stack( + alignment: Alignment.center, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), + child: Text( + '$formatted ${AppConstants.tokenSymbol}', + style: text.extraLargeTitle?.copyWith(color: colors.textSecondary), + ), + ), + Text( + '$formatted ${AppConstants.tokenSymbol}', + style: text.extraLargeTitle?.copyWith(color: colors.textPrimary), + ), + ], + ); + }, + loading: () => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Skeleton(width: 200, height: 36), + Text(' ${AppConstants.tokenSymbol}', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + ], + ), + error: (_, _) => Text('Error loading balance', style: text.detail?.copyWith(color: colors.textError)), + ), + if (!isBalanceHidden) ...[ + const SizedBox(height: 6), + Text('≈ \$0.00', style: text.paragraph?.copyWith(color: colors.textSecondary)), + ], + ], + ), + ); + } + + Widget _buildActionButtons(AppColorsV2 colors, AppTextTheme text) { + return Row( + children: [ + _actionCard( + iconAsset: 'assets/v2/action_receive.svg', + label: 'Receive', + colors: colors, + text: text, + onTap: () => showReceiveSheetV2(context), + ), + const SizedBox(width: 15), + _actionCard( + iconAsset: 'assets/v2/action_send.svg', + label: 'Send', + colors: colors, + text: text, + onTap: () => showSendSheetV2(context), + ), + const SizedBox(width: 15), + _actionCard( + iconAsset: 'assets/v2/action_swap.svg', + label: 'Swap', + colors: colors, + text: text, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SwapScreen())), + ), + ], + ); + } + + Widget _actionCard({ + required String iconAsset, + required String label, + required AppColorsV2 colors, + required AppTextTheme text, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: SizedBox( + height: 80, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset(_actionButtonBgAsset, fit: BoxFit.fill), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset(iconAsset, width: 24, height: 24), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.clip, + style: text.paragraph?.copyWith(color: colors.textPrimary, height: 1.0), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart new file mode 100644 index 00000000..b5bdf12e --- /dev/null +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class ImportWalletScreenV2 extends ConsumerStatefulWidget { + const ImportWalletScreenV2({super.key, this.walletIndex = 0}); + + final int walletIndex; + + @override + ConsumerState createState() => _ImportWalletScreenV2State(); +} + +class _ImportWalletScreenV2State extends ConsumerState { + final _controller = TextEditingController(); + final _settingsService = SettingsService(); + final _accountsService = AccountsService(); + final _discoveryService = AccountDiscoveryService(HdWalletService(), SubstrateService()); + bool _isLoading = false; + String? _error; + + bool get _hasInput => _controller.text.trim().isNotEmpty; + + Future _import() async { + final mnemonic = _controller.text.trim(); + setState(() { + _isLoading = true; + _error = null; + }); + + try { + if (!mnemonic.startsWith('//')) { + final words = mnemonic.split(' ').where((w) => w.isNotEmpty).toList(); + if (words.length != 12 && words.length != 24) { + throw Exception('Recovery phrase must be 12 or 24 words'); + } + } + + final key = HdWalletService().keyPairAtIndex(mnemonic, 0); + await _settingsService.setMnemonic(mnemonic, widget.walletIndex); + await _accountsService.addAccount( + Account(walletIndex: widget.walletIndex, index: 0, name: 'Account 1', accountId: key.ss58Address), + ); + + await _discoverAccounts(mnemonic); + _settingsService.setReferralCheckCompleted(); + _settingsService.setExistingUserSeenPromoVideo(); + + if (!mounted) return; + Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const HomeScreen()), (route) => false); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _discoverAccounts(String mnemonic) async { + try { + final discovered = await _discoveryService.discoverAccounts(mnemonic: mnemonic, walletIndex: widget.walletIndex); + final existing = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); + for (final account in discovered) { + if (!existing.contains(account.accountId)) { + await _accountsService.addAccount(account); + } + } + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + var textSTyleSmallTitle = text.smallTitle?.copyWith( + fontSize: 20, + color: colors.textPrimary, + fontWeight: FontWeight.w400, + height: 1.35, + ); + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Import Wallet', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 24), + ), + ], + ), + const SizedBox(height: 80), + Text( + 'Restore an existing wallet with your 24 word recovery phrase', + textAlign: TextAlign.center, + style: textSTyleSmallTitle, + ), + const SizedBox(height: 64), + Container( + height: 202, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + ), + child: TextField( + controller: _controller, + onChanged: (_) => setState(() {}), + style: textSTyleSmallTitle, + decoration: InputDecoration.collapsed( + hintText: 'Type in or paste your recovery phrase. Separate words with spaces.', + hintStyle: textSTyleSmallTitle?.copyWith(color: colors.textSecondary), + ), + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + Text( + _error!, + style: text.detail?.copyWith(color: colors.error), + textAlign: TextAlign.center, + ), + ], + const Spacer(), + Opacity( + opacity: _hasInput ? 1.0 : 0.2, + child: GlassContainer( + asset: GlassContainer.wideAsset, + onTap: _hasInput && !_isLoading ? _import : null, + child: _isLoading + ? Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: colors.textPrimary), + ), + ) + : Center( + child: Text( + 'Import Wallet', + style: text.paragraph?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: colors.textPrimary, + ), + ), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/mobile-app/lib/v2/screens/receive/receive_sheet.dart b/mobile-app/lib/v2/screens/receive/receive_sheet.dart new file mode 100644 index 00000000..0116898f --- /dev/null +++ b/mobile-app/lib/v2/screens/receive/receive_sheet.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:share_plus/share_plus.dart'; + +class ReceiveSheet extends StatefulWidget { + const ReceiveSheet({super.key}); + + @override + State createState() => _ReceiveSheetState(); +} + +class _ReceiveSheetState extends State { + String? _accountId; + String? _checksum; + Future? _checksumFuture; + + final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); + final SettingsService _settingsService = SettingsService(); + + @override + void initState() { + super.initState(); + _loadAccountData(); + } + + Future _loadAccountData() async { + try { + final account = (await _settingsService.getActiveAccount())!; + setState(() { + _accountId = account.account.accountId; + _checksumFuture = _checksumService.getHumanReadableName(account.account.accountId); + }); + } catch (e) { + debugPrint('Error loading account data: $e'); + } + } + + void _copyAddress() { + if (_accountId != null) { + ClipboardExtensions.copyTextWithSnackbar(context, _accountId!); + } + } + + void _copyChecksum() { + if (_checksum != null) { + ClipboardExtensions.copyTextWithSnackbar(context, _checksum!, message: 'Checkphrase copied'); + } + } + + void _share() { + if (_accountId != null) { + final text = + 'Hey! These are my Quantus account details:\n\nAddress:\n$_accountId' + '${_checksum != null ? '\n\nCheckphrase: $_checksum' : ''}' + '\n\nTo open in the app or download:\n${AppConstants.websiteBaseUrl}/account?id=$_accountId'; + SharePlus.instance.share( + ShareParams( + text: text, + subject: 'Shared Address', + title: 'Shared Address', + sharePositionOrigin: context.sharePositionRect(), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return BottomSheetContainer( + title: 'Receive', + child: _accountId == null + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 80), + child: Center(child: CircularProgressIndicator(color: colors.textPrimary)), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQrCode(colors), + const SizedBox(height: 20), + _buildAddress(colors, text), + const SizedBox(height: 9), + _buildChecksum(colors, text), + const SizedBox(height: 32), + _buildButtons(colors, text), + ], + ), + ); + } + + Widget _buildQrCode(AppColorsV2 colors) { + return SizedBox( + width: 267, + height: 267, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: QrImageView( + data: _accountId!, + version: QrVersions.auto, + size: 267, + padding: const EdgeInsets.all(16), + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), + dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black), + ), + ), + // Container( + // width: 40, + // height: 40, + // decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.white), + // child: ClipOval(child: AccountGradientImage(accountId: _accountId, width: 36.0, height: 36.0)), + // ), + ], + ), + ); + } + + Widget _buildAddress(AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: _copyAddress, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + _accountId!, + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 6), + _copyButton(colors), + ], + ), + ); + } + + Widget _buildChecksum(AppColorsV2 colors, AppTextTheme text) { + return FutureBuilder( + future: _checksumFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: colors.textSecondary), + ); + } + if (!snapshot.hasData || snapshot.data == null || snapshot.data!.isEmpty) return const SizedBox.shrink(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_checksum != snapshot.data && mounted) setState(() => _checksum = snapshot.data!); + }); + + return GestureDetector( + onTap: _copyChecksum, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + snapshot.data!, + style: text.detail?.copyWith(color: colors.accentPink), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 6), + _copyButton(colors), + ], + ), + ); + }, + ); + } + + Widget _copyButton(AppColorsV2 colors) { + return Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(4)), + child: Icon(Icons.copy, size: 12, color: colors.textPrimary), + ); + } + + Widget _buildButtons(AppColorsV2 colors, AppTextTheme text) { + return Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _copyAddress, + child: GlassContainer( + filled: false, + asset: GlassContainer.mediumAsset, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.copy, size: 20, color: colors.textPrimary), + const SizedBox(width: 8), + Text( + 'Copy', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 32), + Expanded( + child: GestureDetector( + onTap: _share, + child: GlassContainer( + filled: true, + asset: GlassContainer.mediumAsset, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.share, size: 20, color: colors.textPrimary), + const SizedBox(width: 8), + Text( + 'Share', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +void showReceiveSheetV2(BuildContext context) { + BottomSheetContainer.show(context, builder: (_) => const ReceiveSheet()); +} diff --git a/mobile-app/lib/v2/screens/send/address_picker_sheet.dart b/mobile-app/lib/v2/screens/send/address_picker_sheet.dart new file mode 100644 index 00000000..ae8c1a36 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/address_picker_sheet.dart @@ -0,0 +1,185 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class AddressPickerSheet extends StatefulWidget { + const AddressPickerSheet({super.key}); + + @override + State createState() => _AddressPickerSheetState(); +} + +class _AddressPickerSheetState extends State { + final _searchController = TextEditingController(); + final _checksumService = HumanReadableChecksumService(); + List _addresses = []; + List _filtered = []; + final Map _checksums = {}; + + @override + void initState() { + super.initState(); + _loadAddresses(); + _searchController.addListener(_filter); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadAddresses() async { + final allAddresses = await RecentAddressesService().getAddresses(); + final active = await SettingsService().getActiveAccount(); + final currentId = active?.account.accountId; + final addresses = allAddresses.where((a) => a != currentId).toList(); + if (!mounted) return; + setState(() { + _addresses = addresses; + _filtered = addresses; + }); + for (final addr in addresses) { + _checksumService.getHumanReadableName(addr).then((name) { + if (mounted) setState(() => _checksums[addr] = name); + }); + } + } + + void _filter() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filtered = query.isEmpty + ? _addresses + : _addresses.where((a) { + final checksum = _checksums[a]?.toLowerCase() ?? ''; + return a.toLowerCase().contains(query) || checksum.contains(query); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Container( + padding: const EdgeInsets.fromLTRB(24, 40, 24, 40), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 530, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Send To', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 40), + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Row( + children: [ + Icon(Icons.search, color: colors.textTertiary, size: 16), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchController, + style: text.smallParagraph?.copyWith(color: colors.textPrimary), + decoration: InputDecoration( + filled: true, + fillColor: Colors.transparent, + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: 'Search', + hintStyle: text.smallParagraph?.copyWith(color: colors.textTertiary), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 40), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Recents', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + const SizedBox(height: 24), + Expanded( + child: _filtered.isEmpty + ? Center( + child: Text('No recent addresses', style: text.detail?.copyWith(color: colors.textTertiary)), + ) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: _filtered.length, + separatorBuilder: (_, _) => const SizedBox(height: 24), + itemBuilder: (context, i) => _addressItem(_filtered[i], colors, text), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _addressItem(String address, AppColorsV2 colors, AppTextTheme text) { + final checksum = _checksums[address]; + return GestureDetector( + onTap: () => Navigator.pop(context, address), + child: Row( + children: [ + AccountGradientImage(accountId: address, width: 40.0, height: 40.0), + const SizedBox(width: 17), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (checksum != null) Text(checksum, style: text.smallParagraph?.copyWith(color: colors.accentPink)), + const SizedBox(height: 4), + Text( + AddressFormattingService.formatAddress(address), + style: text.smallParagraph?.copyWith(color: colors.textSecondary, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +} + +Future showAddressPickerSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), + builder: (_) => BackdropFilter(filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: const AddressPickerSheet()), + ); +} diff --git a/mobile-app/lib/v2/screens/send/send_sheet.dart b/mobile-app/lib/v2/screens/send/send_sheet.dart new file mode 100644 index 00000000..d11a066f --- /dev/null +++ b/mobile-app/lib/v2/screens/send/send_sheet.dart @@ -0,0 +1,573 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/send_screen_logic.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/success_check.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:resonance_network_wallet/v2/screens/send/address_picker_sheet.dart'; + +enum _Step { form, confirm, sending, complete } + +class SendSheet extends ConsumerStatefulWidget { + final String? initialAddress; + const SendSheet({super.key, this.initialAddress}); + + @override + ConsumerState createState() => _SendSheetState(); +} + +class _SendSheetState extends ConsumerState { + final _recipientController = TextEditingController(); + final _amountController = TextEditingController(); + final _fmt = NumberFormattingService(); + final _checksumService = HumanReadableChecksumService(); + + _Step _step = _Step.form; + String? _recipientChecksum; + bool _hasAddressError = true; + BigInt _amount = BigInt.zero; + BigInt _networkFee = BigInt.zero; + int _blockHeight = 0; + bool _isFetchingFee = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _recipientController.addListener(_onRecipientChanged); + _amountController.addListener(_onAmountChanged); + if (widget.initialAddress != null) { + _recipientController.text = widget.initialAddress!; + } + } + + @override + void dispose() { + _recipientController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + void _onRecipientChanged() { + final text = _recipientController.text.trim(); + if (text.isEmpty) { + setState(() { + _hasAddressError = true; + _recipientChecksum = null; + }); + return; + } + _lookupAddress(text); + } + + Future _lookupAddress(String address) async { + final substrate = ref.read(substrateServiceProvider); + final isValid = substrate.isValidSS58Address(address); + final checksum = isValid ? await _checksumService.getHumanReadableName(address) : null; + if (!mounted) return; + setState(() { + _hasAddressError = !isValid; + _recipientChecksum = checksum; + }); + if (isValid && _amount > BigInt.zero) _fetchFee(); + } + + void _onAmountChanged() { + final parsed = _fmt.parseAmount(_amountController.text); + setState(() => _amount = parsed ?? BigInt.zero); + if (!_hasAddressError && _amount > BigInt.zero) _fetchFee(); + } + + Future _fetchFee() async { + if (_isFetchingFee) return; + setState(() => _isFetchingFee = true); + try { + final displayAccount = ref.read(activeAccountProvider).value; + if (displayAccount is! RegularAccount) return; + final recipient = _recipientController.text.trim(); + final balancesService = ref.read(balancesServiceProvider); + final feeData = await balancesService.getBalanceTransferFee(displayAccount.account, recipient, _amount); + if (!mounted) return; + setState(() { + _networkFee = feeData.fee; + _blockHeight = feeData.blockNumber; + }); + } catch (e) { + debugPrint('Fee fetch error: $e'); + } finally { + if (mounted) setState(() => _isFetchingFee = false); + } + } + + void _setMax() { + final balance = ref.read(effectiveMaxBalanceProvider).value ?? BigInt.zero; + final max = SendScreenLogic.calculateMaxSendableAmount(balance: balance, networkFee: _networkFee); + _amountController.text = _fmt.formatBalance(max, addThousandsSeparators: false); + } + + Future _scanQr() async { + final address = await Navigator.push( + context, + MaterialPageRoute(fullscreenDialog: true, builder: (_) => const _QrScanPage()), + ); + if (address != null && mounted) { + _recipientController.text = address; + } + } + + Future _pickRecent() async { + final address = await showAddressPickerSheet(context); + if (address != null && mounted) { + _recipientController.text = address; + } + } + + void _review() => setState(() => _step = _Step.confirm); + void _backToForm() => setState(() => _step = _Step.form); + + Future _confirmSend() async { + setState(() { + _step = _Step.sending; + _errorMessage = null; + }); + try { + final settings = SettingsService(); + final account = (await settings.getActiveRegularAccount())!; + final submissionService = ref.read(transactionSubmissionServiceProvider); + await submissionService.balanceTransfer( + account, + _recipientController.text.trim(), + _amount, + _networkFee, + _blockHeight, + ); + RecentAddressesService().addAddress(_recipientController.text.trim()); + if (mounted) setState(() => _step = _Step.complete); + } catch (e) { + if (mounted) { + setState(() { + _step = _Step.confirm; + _errorMessage = 'Transfer failed: $e'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final balance = ref.watch(effectiveMaxBalanceProvider); + + return Container( + padding: const EdgeInsets.fromLTRB(24, 40, 24, 40), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + child: switch (_step) { + _Step.form => _buildForm(colors, text, balance), + _Step.confirm => _buildConfirm(colors, text), + _Step.sending => _buildSending(colors, text), + _Step.complete => _buildComplete(colors, text), + }, + ), + ), + ); + } + + Widget _header(AppColorsV2 colors, AppTextTheme text, {VoidCallback? onBack}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (onBack != null) + AppBackButton(onTap: onBack) + else + Text('Send', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ); + } + + Widget _buildForm(AppColorsV2 colors, AppTextTheme text, AsyncValue balance) { + final recipient = _recipientController.text.trim(); + final activeId = ref.watch(activeAccountProvider).value?.account.accountId ?? ''; + final amountStatus = SendScreenLogic.getAmountStatus(_amount, balance.value ?? BigInt.zero, _networkFee); + final btnDisabled = SendScreenLogic.isButtonDisabled( + hasAddressError: _hasAddressError, + amountStatus: amountStatus, + recipientText: recipient, + activeAccountId: activeId, + isFetchingFee: _isFetchingFee, + ); + final btnText = SendScreenLogic.getButtonText( + hasAddressError: _hasAddressError, + amountStatus: amountStatus, + recipientText: recipient, + amount: _amount, + activeAccountId: activeId, + formattingService: _fmt, + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(colors, text), + const SizedBox(height: 40), + Text('Send To', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 12), + _addressInput(colors, text), + const SizedBox(height: 12), + Row( + children: [ + _iconButton(Icons.qr_code_scanner, colors, _scanQr), + const SizedBox(width: 8), + _iconButton(Icons.history, colors, _pickRecent), + ], + ), + const SizedBox(height: 40), + _amountCard(colors, text, balance), + const SizedBox(height: 12), + _feeRow(colors, text), + const SizedBox(height: 8), + _actionButton( + label: btnText, + colors: colors, + text: text, + disabled: btnDisabled, + onTap: btnDisabled ? null : _review, + ), + ], + ); + } + + Widget _addressInput(AppColorsV2 colors, AppTextTheme text) { + final hasRecipient = _recipientController.text.trim().isNotEmpty && !_hasAddressError; + if (hasRecipient) { + return GestureDetector( + onTap: () => _recipientController.clear(), + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 14, 8, 14), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AddressFormattingService.formatAddress( + _recipientController.text.trim(), + prefix: 15, + ellipses: '.......', + postFix: 14, + ), + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_recipientChecksum != null) ...[ + const SizedBox(height: 4), + Text(_recipientChecksum!, style: text.smallParagraph?.copyWith(color: colors.accentPink)), + ], + ], + ), + ), + ); + } + return SizedBox( + width: double.infinity, + height: 56, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.only(left: 12, right: 8), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + child: TextField( + controller: _recipientController, + textAlignVertical: TextAlignVertical.center, + style: text.smallParagraph?.copyWith(color: colors.textPrimary), + decoration: InputDecoration( + filled: true, + fillColor: Colors.transparent, + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: 'Quan Address', + hintStyle: text.smallParagraph?.copyWith(color: colors.textTertiary), + ), + ), + ), + ); + } + + Widget _amountCard(AppColorsV2 colors, AppTextTheme text, AsyncValue balance) { + return SizedBox( + height: 120, + child: Stack( + children: [ + Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + ), + Positioned( + left: 20, + right: 20, + top: 20, + child: TextField( + controller: _amountController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [DecimalInputFilter()], + style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontSize: 32), + decoration: InputDecoration( + isDense: true, + filled: false, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: '0 ${AppConstants.tokenSymbol}', + hintStyle: text.mediumTitle?.copyWith(color: colors.textTertiary, fontSize: 32), + ), + ), + ), + Positioned( + left: 20, + right: 20, + bottom: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Available: ${balance.when(data: (b) => _fmt.formatBalance(b), loading: () => '...', error: (_, _) => '0')} ${AppConstants.tokenSymbol}', + style: text.detail?.copyWith(color: colors.textSecondary), + ), + GestureDetector( + onTap: _hasAddressError ? null : _setMax, + child: Text('Max', style: text.detail?.copyWith(color: colors.textSecondary)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _feeRow(AppColorsV2 colors, AppTextTheme text) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Network Fee:', style: text.detail?.copyWith(color: colors.textSecondary)), + Text( + _isFetchingFee ? '...' : '${_fmt.formatBalance(_networkFee)} ${AppConstants.tokenSymbol}', + style: text.detail?.copyWith(color: colors.textSecondary), + ), + ], + ); + } + + Widget _buildConfirm(AppColorsV2 colors, AppTextTheme text) { + final recipient = _recipientController.text.trim(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(colors, text, onBack: _backToForm), + const SizedBox(height: 72), + Text( + '${_fmt.formatBalance(_amount)} ${AppConstants.tokenSymbol}', + style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontSize: 32), + ), + const SizedBox(height: 64), + Text( + 'To:', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + Text( + AddressFormattingService.formatAddress(recipient, prefix: 15, ellipses: '.......', postFix: 14), + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + if (_recipientChecksum != null) ...[ + const SizedBox(height: 4), + Text(_recipientChecksum!, style: text.smallParagraph?.copyWith(color: colors.accentPink)), + ], + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text(_errorMessage!, style: text.detail?.copyWith(color: colors.textError)), + ], + const SizedBox(height: 64), + _feeRow(colors, text), + const SizedBox(height: 8), + _actionButton(label: 'Confirm', colors: colors, text: text, onTap: _confirmSend), + ], + ); + } + + Widget _buildSending(AppColorsV2 colors, AppTextTheme text) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(colors, text), + const SizedBox(height: 80), + CircularProgressIndicator(color: colors.textPrimary), + const SizedBox(height: 24), + Text('Sending...', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 80), + ], + ); + } + + Widget _buildComplete(AppColorsV2 colors, AppTextTheme text) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _header(colors, text), + const SizedBox(height: 80), + const SuccessCheck(size: 64), + const SizedBox(height: 24), + Text('Sent!', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 8), + Text( + '${_fmt.formatBalance(_amount)} ${AppConstants.tokenSymbol}', + style: text.paragraph?.copyWith(color: colors.textSecondary), + ), + const SizedBox(height: 80), + _actionButton(label: 'Done', colors: colors, text: text, onTap: () => Navigator.pop(context)), + ], + ); + } + + Widget _iconButton(IconData icon, AppColorsV2 colors, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 40, + height: 40, + child: GlassContainer( + asset: GlassContainer.smallAsset, + child: Icon(icon, color: colors.textPrimary, size: 20), + ), + ), + ); + } + + Widget _actionButton({ + required String label, + required AppColorsV2 colors, + required AppTextTheme text, + bool disabled = false, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: disabled ? null : onTap, + child: Opacity( + opacity: disabled ? 0.2 : 1.0, + child: GlassContainer( + asset: GlassContainer.wideAsset, + child: Text( + label, + textAlign: TextAlign.center, + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } +} + +void showSendSheetV2(BuildContext context, {String? address}) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), + builder: (_) => BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SendSheet(initialAddress: address), + ), + ), + ); +} + +class _QrScanPage extends StatefulWidget { + const _QrScanPage(); + + @override + State<_QrScanPage> createState() => _QrScanPageState(); +} + +class _QrScanPageState extends State<_QrScanPage> { + final _controller = MobileScannerController(); + bool _scanned = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: (capture) { + if (_scanned) return; + for (final barcode in capture.barcodes) { + final v = barcode.rawValue; + if (v != null && v.isNotEmpty) { + _scanned = true; + Navigator.pop(context, v); + return; + } + } + }, + ), + Positioned( + bottom: 60, + left: 0, + right: 0, + child: Center( + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + decoration: BoxDecoration(color: const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(14)), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/settings/auto_lock_screen.dart b/mobile-app/lib/v2/screens/settings/auto_lock_screen.dart new file mode 100644 index 00000000..14e19f54 --- /dev/null +++ b/mobile-app/lib/v2/screens/settings/auto_lock_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class AutoLockScreen extends StatefulWidget { + const AutoLockScreen({super.key}); + + @override + State createState() => _AutoLockScreenState(); +} + +class _AutoLockScreenState extends State { + final _authService = LocalAuthService(); + late int _selected; + + static const _options = [ + (value: 0, label: '30 Seconds'), + (value: 1, label: '1 minute'), + (value: 5, label: '5 minutes'), + (value: 15, label: '15 minutes'), + (value: 60, label: '1 hour'), + (value: -1, label: 'Never'), + ]; + + @override + void initState() { + super.initState(); + _selected = _authService.getAuthTimeoutMinutes(); + } + + void _confirm() { + _authService.setAuthTimeoutMinutes(_selected); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Auto-Lock', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(width: 24), + ], + ), + const SizedBox(height: 80), + Text( + 'Automatically lock wallet after\nperiod of inactivity', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500, height: 1.35), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Column( + children: [ + for (var i = 0; i < _options.length; i++) ...[ + if (i > 0) Divider(color: colors.separator, height: 1), + _optionRow(_options[i].value, _options[i].label, colors, text), + ], + ], + ), + ), + const Spacer(), + GlassContainer( + asset: GlassContainer.wideAsset, + onTap: _confirm, + child: Center( + child: Text( + 'Confirm', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _optionRow(int value, String label, AppColorsV2 colors, AppTextTheme text) { + final selected = _selected == value; + return GestureDetector( + onTap: () => setState(() => _selected = value), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: selected ? colors.textPrimary : colors.textSecondary, + size: 24, + ), + const SizedBox(width: 8), + Text(label, style: text.paragraph?.copyWith(color: colors.textPrimary)), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/settings/change_pin_screen.dart b/mobile-app/lib/v2/screens/settings/change_pin_screen.dart new file mode 100644 index 00000000..192d9137 --- /dev/null +++ b/mobile-app/lib/v2/screens/settings/change_pin_screen.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/components/success_check.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +enum _Step { enterCurrent, enterNew, confirmNew, success } + +class ChangePinScreen extends StatefulWidget { + const ChangePinScreen({super.key}); + + @override + State createState() => _ChangePinScreenState(); +} + +class _ChangePinScreenState extends State { + final _settingsService = SettingsService(); + final _focusNode = FocusNode(); + final _controller = TextEditingController(); + var _step = _Step.enterCurrent; + String _newPin = ''; + String? _error; + + @override + void initState() { + super.initState(); + _checkExistingPin(); + _controller.addListener(() { + setState(() => _error = null); + if (_controller.text.length == 6) _onContinue(); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + Future _checkExistingPin() async { + final has = await _settingsService.hasPin(); + if (!mounted) return; + setState(() => _step = has ? _Step.enterCurrent : _Step.enterNew); + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); + } + + Future _onContinue() async { + final entry = _controller.text; + if (entry.length != 6) return; + + switch (_step) { + case _Step.enterCurrent: + final ok = await _settingsService.verifyPin(entry); + if (!ok) { + setState(() => _error = 'Incorrect PIN'); + HapticFeedback.heavyImpact(); + return; + } + _controller.clear(); + setState(() => _step = _Step.enterNew); + _focusNode.requestFocus(); + case _Step.enterNew: + _newPin = entry; + _controller.clear(); + setState(() => _step = _Step.confirmNew); + _focusNode.requestFocus(); + case _Step.confirmNew: + if (entry != _newPin) { + setState(() => _error = 'PINs do not match'); + HapticFeedback.heavyImpact(); + return; + } + await _settingsService.setPin(_newPin); + _focusNode.unfocus(); + setState(() => _step = _Step.success); + HapticFeedback.mediumImpact(); + case _Step.success: + break; + } + } + + String get _stepTitle { + switch (_step) { + case _Step.enterCurrent: + return 'Enter Current PIN'; + case _Step.enterNew: + return 'Enter New PIN'; + case _Step.confirmNew: + return 'Confirm New PIN'; + case _Step.success: + return ''; + } + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: colors.background, + resizeToAvoidBottomInset: false, + body: GradientBackground( + child: SafeArea( + child: Stack( + children: [ + Opacity( + opacity: 0, + child: SizedBox( + height: 0, + child: TextField( + controller: _controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + keyboardAppearance: Brightness.dark, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], + decoration: const InputDecoration(counterText: ''), + ), + ), + ), + Column( + children: [ + const SizedBox(height: 16), + _header(colors, text), + Expanded(child: _step == _Step.success ? _successBody(colors, text) : _pinBody(colors, text)), + if (_step == _Step.success) ...[_doneButton(colors, text), const SizedBox(height: 24)], + ], + ), + ], + ), + ), + ), + ); + } + + Widget _header(AppColorsV2 colors, AppTextTheme text) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Change PIN', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(width: 24), + ], + ), + ); + } + + Widget _pinBody(AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: () => _focusNode.requestFocus(), + behavior: HitTestBehavior.opaque, + child: Column( + children: [ + const SizedBox(height: 80), + Text( + _stepTitle, + style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 24, fontWeight: FontWeight.w400), + ), + const SizedBox(height: 32), + _pinDots(colors), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: text.detail?.copyWith(color: colors.error)), + ], + const Spacer(), + ], + ), + ); + } + + Widget _pinDots(AppColorsV2 colors) { + final entry = _controller.text; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(6, (i) { + final filled = i < entry.length; + return Container( + width: 40, + height: 48, + margin: EdgeInsets.only(left: i == 0 ? 0 : 8), + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset('assets/v2/pin_number_background.png', width: 40, height: 48), + if (filled) + Container( + width: 8, + height: 8, + decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), + ), + ], + ), + ); + }), + ); + } + + Widget _successBody(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + const Spacer(flex: 2), + const SuccessCheck(), + const SizedBox(height: 64), + Text('PIN Changed', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'Your PIN has been updated successfully', + style: text.paragraph?.copyWith(color: colors.textSecondary), + textAlign: TextAlign.center, + ), + ), + const Spacer(flex: 3), + ], + ); + } + + Widget _doneButton(AppColorsV2 colors, AppTextTheme text) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: GlassContainer( + asset: GlassContainer.wideAsset, + filled: true, + child: Center( + child: Text( + 'Done', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/settings/recovery_phrase_screen.dart b/mobile-app/lib/v2/screens/settings/recovery_phrase_screen.dart new file mode 100644 index 00000000..cf7222db --- /dev/null +++ b/mobile-app/lib/v2/screens/settings/recovery_phrase_screen.dart @@ -0,0 +1,212 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/snackbar_helper.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class RecoveryPhraseScreen extends StatefulWidget { + const RecoveryPhraseScreen({super.key, this.walletIndex = 0}); + + final int walletIndex; + + @override + State createState() => _RecoveryPhraseScreenState(); +} + +class _RecoveryPhraseScreenState extends State { + final _settingsService = SettingsService(); + final _authService = LocalAuthService(); + List _words = []; + bool _revealed = false; + + Future _toggleReveal() async { + if (_revealed) { + setState(() { + _revealed = false; + _words = []; + }); + return; + } + if (_authService.isLocalAuthEnabled()) { + final ok = await _authService.authenticate( + localizedReason: 'Authenticate to reveal recovery phrase', + biometricOnly: false, + ); + if (!ok || !mounted) return; + } + final mnemonic = await _settingsService.getMnemonic(widget.walletIndex); + if (mnemonic != null && mounted) { + setState(() { + _words = mnemonic.split(' '); + _revealed = true; + }); + } + } + + void _copyToClipboard() { + Clipboard.setData(ClipboardData(text: _words.join(' '))); + showCopySnackbar(context, title: 'Copied!', message: 'Recovery phrase copied to clipboard'); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Recovery Phrase', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(width: 24), + ], + ), + const SizedBox(height: 40), + _warning(colors, text), + const SizedBox(height: 40), + Expanded(child: SingleChildScrollView(child: _wordGrid(colors, text))), + const SizedBox(height: 16), + IgnorePointer( + ignoring: !_revealed, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _revealed ? 1.0 : 0.0, + child: _copyRow(colors, text), + ), + ), + const SizedBox(height: 16), + _revealButton(colors, text), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _warning(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.warning_amber_rounded, color: colors.accentPink, size: 24), + const SizedBox(width: 8), + Text('Important Warning', style: text.smallTitle?.copyWith(color: colors.accentPink)), + ], + ), + const SizedBox(height: 8), + Text( + 'Your recovery phrase is the only way to restore your wallet. Never share it with anyone. Anyone with your recovery phrase has full access to your funds.', + style: text.smallParagraph?.copyWith(color: colors.textSecondary, height: 1.5), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _wordGrid(AppColorsV2 colors, AppTextTheme text) { + final count = _revealed ? _words.length : 24; + final rows = []; + for (var i = 0; i < count; i += 3) { + final chips = []; + for (var j = i; j < i + 3 && j < count; j++) { + final word = _revealed ? _words[j] : 'blurred'; + chips.add(Expanded(child: _wordChip(j + 1, word, colors, text))); + if (j < i + 2 && j < count - 1) chips.add(const SizedBox(width: 9)); + } + if (rows.isNotEmpty) rows.add(const SizedBox(height: 9)); + rows.add(Row(children: chips)); + } + return Column(children: rows); + } + + Widget _wordChip(int index, String word, AppColorsV2 colors, AppTextTheme text) { + final wordWidget = Text( + word, + textHeightBehavior: const TextHeightBehavior(applyHeightToFirstAscent: false), + style: text.detail?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ); + + return SizedBox( + child: GlassContainer(asset: GlassContainer.mediumSmallAsset, filled: true, child: Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [const SizedBox(width: 8), Text('$index', style: text.detail?.copyWith(color: colors.textSecondary)), const SizedBox(width: 6), Expanded( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 50), + opacity: _revealed ? 1.0 : 0.0, + child: wordWidget, + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 50), + opacity: _revealed ? 0.0 : 1.0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), child: wordWidget), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _copyRow(AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: _copyToClipboard, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Copy to clipboard', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(width: 8), + Icon(Icons.copy, color: colors.textPrimary, size: 14), + ], + ), + ); + } + + Widget _revealButton(AppColorsV2 colors, AppTextTheme text) { + return GlassContainer( + asset: GlassContainer.wideAsset, + onTap: _toggleReveal, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _revealed ? Icons.visibility_off_outlined : Icons.visibility_outlined, + color: colors.textPrimary, + size: 16, + ), + const SizedBox(width: 8), + Text( + _revealed ? 'Hide Recovery Phrase' : 'Reveal Recovery Phrase', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart b/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart new file mode 100644 index 00000000..3c834205 --- /dev/null +++ b/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/recovery_phrase_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class SelectWalletScreen extends ConsumerWidget { + const SelectWalletScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final text = context.themeText; + final accountsAsync = ref.watch(accountsProvider); + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Select Wallet', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(width: 24), + ], + ), + const SizedBox(height: 48), + Expanded( + child: accountsAsync.when( + loading: () => const Center(child: CircularProgressIndicator(color: Colors.white24)), + error: (e, _) => Center( + child: Text( + 'Failed to load wallets', + style: text.paragraph?.copyWith(color: colors.textSecondary), + ), + ), + data: (accounts) { + final indices = getNonHardwareWalletIndices(accounts); + if (indices.isEmpty) { + return Center( + child: Text('No wallets found', style: text.paragraph?.copyWith(color: colors.textSecondary)), + ); + } + return ListView.separated( + itemCount: indices.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (_, i) => _walletItem(context, indices[i], colors, text), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _walletItem(BuildContext context, int walletIndex, AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => RecoveryPhraseScreen(walletIndex: walletIndex))), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: colors.surfaceCard, borderRadius: BorderRadius.circular(14)), + child: Row( + children: [ + Expanded( + child: Text( + 'Wallet ${walletIndex + 1}', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + Icon(Icons.chevron_right, color: colors.textSecondary, size: 20), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/settings/settings_screen.dart b/mobile-app/lib/v2/screens/settings/settings_screen.dart new file mode 100644 index 00000000..ea814254 --- /dev/null +++ b/mobile-app/lib/v2/screens/settings/settings_screen.dart @@ -0,0 +1,392 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/reset_confirmation_bottom_sheet.dart'; +import 'package:resonance_network_wallet/features/components/snackbar_helper.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/recovery_phrase_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/select_wallet_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/welcome/welcome_screen.dart'; +import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/notification_config_provider.dart'; +import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/auto_lock_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/change_pin_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsScreenV2 extends ConsumerStatefulWidget { + const SettingsScreenV2({super.key}); + + @override + ConsumerState createState() => _SettingsScreenV2State(); +} + +class _SettingsScreenV2State extends ConsumerState { + final _authService = LocalAuthService(); + final _settingsService = SettingsService(); + bool _biometricEnabled = false; + String _biometricDesc = 'Face ID Disabled'; + int _autoLockMinutes = 5; + bool _reversibleEnabled = false; + int _reversibleTimeSeconds = 600; + bool _hasPinSet = false; + + @override + void initState() { + super.initState(); + _loadSettings(); + _loadPinState(); + } + + Future _loadPinState() async { + final has = await _settingsService.hasPin(); + if (mounted) setState(() => _hasPinSet = has); + } + + Future _loadSettings() async { + final bioEnabled = _authService.isLocalAuthEnabled(); + final bioDesc = await _authService.getBiometricDescription(); + final timeout = _authService.getAuthTimeoutMinutes(); + final revTime = await _settingsService.getReversibleTimeSeconds() ?? 600; + final revEnabled = _settingsService.isReversibleEnabled(); + + if (!mounted) return; + setState(() { + _biometricEnabled = bioEnabled; + _biometricDesc = bioEnabled ? bioDesc : 'Face ID Disabled'; + _autoLockMinutes = timeout; + _reversibleTimeSeconds = revTime; + _reversibleEnabled = revEnabled; + }); + } + + Future _toggleBiometric(bool enable) async { + if (enable) { + final available = await _authService.isBiometricAvailable(); + if (!available) { + if (mounted) showTopSnackBar(context, title: 'Error', message: 'Biometric not available on this device'); + return; + } + } + final ok = await _authService.authenticate( + localizedReason: 'Authenticate to ${enable ? 'enable' : 'disable'} biometric', + biometricOnly: false, + forSetup: true, + ); + if (ok) { + _authService.setLocalAuthEnabled(enable); + _loadSettings(); + } + } + + void _toggleNotifications(bool enable) { + final current = ref.read(notificationConfigProvider); + ref.read(notificationConfigProvider.notifier).updateConfig(current.copyWith(enabled: enable)); + } + + void _navigateToRecoveryPhrase() { + final accountsAsync = ref.read(accountsProvider); + accountsAsync.whenData((accounts) { + final walletIndices = getNonHardwareWalletIndices(accounts); + if (walletIndices.isEmpty) return; + if (walletIndices.length == 1) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => RecoveryPhraseScreen(walletIndex: walletIndices.first)), + ); + } else { + Navigator.push(context, MaterialPageRoute(builder: (_) => const SelectWalletScreen())); + } + }); + } + + void _resetAndClearData() { + _settingsService.clearAll(); + SubstrateService().logout(); + ref.read(pendingTransactionsProvider.notifier).clear(); + ref.read(accountsProvider.notifier).reset(); + ref.read(activeAccountProvider.notifier).reset(); + ref.read(accountAssociationsProvider.notifier).reset(); + if (mounted) { + Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const WelcomeScreenV2()), (r) => false); + } + } + + void _showResetConfirmation() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => ResetConfirmationBottomSheet(onReset: _resetAndClearData), + ); + } + + String _autoLockLabel() { + if (_autoLockMinutes == 0) return 'Immediately'; + if (_autoLockMinutes == 60) return '1 hour'; + return '$_autoLockMinutes mins'; + } + + String _timeLimitLabel() { + if (_reversibleTimeSeconds <= 0) return 'Disabled'; + final mins = _reversibleTimeSeconds ~/ 60; + if (mins < 60) return '$mins minutes'; + final hours = mins ~/ 60; + final remMins = mins % 60; + return remMins > 0 ? '${hours}h ${remMins}m' : '$hours hours'; + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final notifConfig = ref.watch(notificationConfigProvider); + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Column( + children: [ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Settings', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(width: 24), + ], + ), + ), + const SizedBox(height: 48), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + _section('Security', colors, text, [ + _toggleItem('Biometric Lock', _biometricDesc, _biometricEnabled, _toggleBiometric, colors, text), + _divider(colors), + _chevronItem( + 'PIN Code', + _hasPinSet ? '6-digit code' : 'Not set', + colors, + text, + onTap: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChangePinScreen())); + _loadPinState(); + }, + ), + _divider(colors), + _chevronItem( + 'Auto-Lock', + _autoLockLabel(), + colors, + text, + onTap: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const AutoLockScreen())); + _loadSettings(); + }, + ), + ]), + const SizedBox(height: 40), + _section('Wallet', colors, text, [ + _chevronItem('Recovery Phase', 'View Backup', colors, text, onTap: _navigateToRecoveryPhrase), + ]), + const SizedBox(height: 40), + _section('Reversible Transactions', colors, text, [ + _toggleItem( + 'Reversible Transactions', + 'Coming Soon', //_reversibleEnabled ? 'Enabled' : 'Disabled', + _reversibleEnabled, + null, + colors, + text, + ), + _divider(colors), + _chevronItem('Time Limit', _timeLimitLabel(), colors, text, onTap: () {}), + _divider(colors), + _chevronItem('Amount Limit', 'No Limit', colors, text, onTap: () {}), + ]), + const SizedBox(height: 40), + _section('Account Type', colors, text, [ + _comingSoonItem('High Security Account', 'Guardian Approval', colors, text), + _divider(colors), + _comingSoonItem('Multi-Signature', 'Multiple Accounts', colors, text), + _divider(colors), + _comingSoonItem('Hardware Wallet', 'Pair Device', colors, text), + ]), + const SizedBox(height: 40), + _section('Preferences', colors, text, [ + // _chevronItem('Currency', 'USD (\$)', colors, text, onTap: () {}), + // _divider(colors), + _toggleItem( + 'Notifications', + notifConfig.enabled ? 'Transaction Alerts Enabled' : 'Alerts Disabled', + notifConfig.enabled, + _toggleNotifications, + colors, + text, + ), + ]), + const SizedBox(height: 40), + _section('About & Support', colors, text, [ + _externalItem( + 'Help & Support', + null, + colors, + text, + onTap: () => launchUrl(Uri.parse(AppConstants.helpAndSupportUrl)), + ), + _divider(colors), + _externalItem( + 'Privacy & Terms of Service', + null, + colors, + text, + onTap: () => launchUrl(Uri.parse(AppConstants.termsOfServiceUrl)), + ), + ]), + const SizedBox(height: 40), + _resetButton(colors, text), + const SizedBox(height: 48), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _section(String title, AppColorsV2 colors, AppTextTheme text, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: colors.surfaceCard, borderRadius: BorderRadius.circular(14)), + child: Column(children: children), + ), + ], + ); + } + + Column _itemContent(String title, AppTextTheme text, AppColorsV2 colors, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: text.paragraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 4), + Text(subtitle, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), + ], + ); + } + + Widget _toggleItem( + String title, + String subtitle, + bool value, + ValueChanged? onChanged, + AppColorsV2 colors, + AppTextTheme text, + ) { + return Row( + children: [ + Expanded(child: _itemContent(title, text, colors, subtitle)), + CupertinoSwitch(value: value, onChanged: onChanged, activeTrackColor: colors.accentGreen), + ], + ); + } + + Widget _chevronItem( + String title, + String subtitle, + AppColorsV2 colors, + AppTextTheme text, { + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Expanded(child: _itemContent(title, text, colors, subtitle)), + Icon(Icons.chevron_right, color: colors.textSecondary, size: 20), + ], + ), + ); + } + + Widget _externalItem( + String title, + String? subtitle, + AppColorsV2 colors, + AppTextTheme text, { + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Expanded( + child: subtitle != null + ? _itemContent(title, text, colors, subtitle) + : Text(title, style: text.paragraph?.copyWith(color: colors.textPrimary)), + ), + Icon(Icons.north_east, color: colors.textSecondary, size: 20), + ], + ), + ); + } + + Widget _comingSoonItem(String title, String subtitle, AppColorsV2 colors, AppTextTheme text) { + return Row( + children: [ + Expanded(child: _itemContent(title, text, colors, subtitle)), + Text('Coming Soon', style: text.detail?.copyWith(color: colors.textTertiary)), + ], + ); + } + + Widget _divider(AppColorsV2 colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Divider(color: colors.separator, height: 1), + ); + } + + Widget _resetButton(AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: _showResetConfirmation, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.danger), + ), + child: Center( + child: Text( + 'Reset Quantus', + style: text.paragraph?.copyWith(color: colors.danger, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/deposit_screen.dart new file mode 100644 index 00000000..06f7979e --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/deposit_screen.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/components/success_check.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class DepositScreen extends StatefulWidget { + final SwapOrder order; + const DepositScreen({super.key, required this.order}); + + @override + State createState() => _DepositScreenState(); +} + +class _DepositScreenState extends State { + final _swapService = SwapService(); + late SwapOrder _order; + bool _confirming = false; + + @override + void initState() { + super.initState(); + _order = widget.order; + } + + Future _confirmSent() async { + setState(() => _confirming = true); + try { + final updated = await _swapService.confirmFundsSent(_order.orderId); + if (!mounted) return; + setState(() { + _order = updated; + _confirming = false; + }); + _pollStatus(); + } catch (e) { + setState(() => _confirming = false); + } + } + + Future _pollStatus() async { + while (mounted && _order.status == SwapStatus.processing) { + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + try { + final updated = await _swapService.getSwapStatus(_order.orderId); + if (!mounted) return; + setState(() => _order = updated); + } catch (_) {} + } + } + + void _copyAddress() { + Clipboard.setData(ClipboardData(text: _order.depositAddress)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Address copied'), duration: Duration(seconds: 1))); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final quote = _order.quote; + final usd = quote.fromAmount * _swapService.getUsdPrice(quote.fromToken); + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + _header(colors, text), + const SizedBox(height: 40), + if (_order.status == SwapStatus.complete) + _completedBody(colors, text) + else if (_order.status == SwapStatus.processing) + _processingBody(colors, text) + else + _depositBody(colors, text, quote, usd), + const Spacer(), + if (_order.status == SwapStatus.depositing) _sentButton(colors, text), + if (_order.status == SwapStatus.complete) _doneButton(colors, text), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _header(AppColorsV2 colors, AppTextTheme text) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Swap', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + Icon(Icons.info_outline, color: colors.textPrimary, size: 24), + ], + ); + } + + Widget _depositBody(AppColorsV2 colors, AppTextTheme text, SwapQuote quote, double usd) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Deposit Amount', style: text.smallParagraph?.copyWith(color: colors.textPrimary, height: 1.35)), + const SizedBox(width: 6), + GestureDetector( + onTap: () => Clipboard.setData(ClipboardData(text: quote.totalAmount.toStringAsFixed(2))), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(4)), + child: Center(child: Icon(Icons.copy, color: colors.textPrimary, size: 12)), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration(color: colors.accentPink.withValues(alpha: 0.3), shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + quote.totalAmount.toStringAsFixed(2), + style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 8), + Text( + '\$${usd.toStringAsFixed(2)}', + style: text.smallParagraph?.copyWith(color: colors.textSecondary, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 40), + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Container( + color: Colors.white, + padding: const EdgeInsets.all(8), + /// for now this QR Code is invalid so people don't transfer by accident + // child: QrImageView(data: _order.depositAddress, version: QrVersions.auto, size: 184), + child: QrImageView(data: 'quantum secure bitcoin - quantus!', version: QrVersions.auto, size: 184), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 264, + child: Stack( + children: [ + // for now put invalid address so people don't transfer by accident + Text( + // _order.depositAddress.toLowerCase(), + '-------------------', + style: text.smallParagraph?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w500, + height: 1.35, + ), + textAlign: TextAlign.center, + ), + Positioned( + right: 0, + top: 19, + child: GestureDetector( + onTap: _copyAddress, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(4)), + child: Center(child: Icon(Icons.copy, color: colors.textPrimary, size: 12)), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: GlassContainer( + filled: false, + asset: GlassContainer.mediumAsset, + onTap: _copyAddress, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.copy, color: colors.textPrimary, size: 20), + const SizedBox(width: 8), + Text( + 'Copy', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: GlassContainer( + filled: false, + asset: GlassContainer.mediumAsset, + onTap: () {}, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.qr_code, color: colors.textPrimary, size: 20), + const SizedBox(width: 8), + Text( + 'Share QR', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 40), + Text.rich( + TextSpan( + style: text.detail?.copyWith(color: colors.textSecondary, height: 1.35), + children: [ + const TextSpan(text: 'Use your '), + TextSpan( + text: quote.fromToken.symbol, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const TextSpan(text: ' or '), + TextSpan( + text: quote.fromToken.network, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const TextSpan(text: ' wallet to deposit funds. Depositing other assets may result in loss of funds.'), + ], + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _processingBody(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + const SizedBox(height: 80), + CircularProgressIndicator(color: colors.accentGreen), + const SizedBox(height: 32), + Text('Processing Swap', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(height: 12), + Text('This may take a few minutes...', style: text.paragraph?.copyWith(color: colors.textSecondary)), + ], + ); + } + + Widget _completedBody(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + const SizedBox(height: 80), + const SuccessCheck(), + const SizedBox(height: 32), + Text('Swap Complete', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + const SizedBox(height: 12), + Text( + 'Your swap for ${_order.quote.toAmount.toStringAsFixed(2)} QUAN is processing.', + style: text.paragraph?.copyWith(color: colors.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + if (AppConstants.stillOnTestnet) + Text( + 'DEMO ONLY - WE ARE STILL ON TESTNET', + style: text.paragraph?.copyWith(color: Colors.yellow), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _sentButton(AppColorsV2 colors, AppTextTheme text) { + return GlassContainer( + asset: GlassContainer.wideAsset, + filled: false, + onTap: _confirming ? null : _confirmSent, + child: Center( + child: _confirming + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(color: colors.textPrimary, strokeWidth: 2), + ) + : Text( + "I've sent the funds", + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ); + } + + Widget _doneButton(AppColorsV2 colors, AppTextTheme text) { + return GlassContainer( + asset: GlassContainer.wideAsset, + filled: false, + onTap: () => Navigator.popUntil(context, (r) => r.isFirst), + child: Center( + child: Text( + 'Done', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart new file mode 100644 index 00000000..d6961238 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart @@ -0,0 +1,110 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +Future showRefundAddressPickerSheet(BuildContext context, String network) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width), + builder: (_) => BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: _RefundAddressPickerContent(network: network), + ), + ); +} + +class _RefundAddressPickerContent extends StatefulWidget { + final String network; + const _RefundAddressPickerContent({required this.network}); + + @override + State<_RefundAddressPickerContent> createState() => _RefundAddressPickerContentState(); +} + +class _RefundAddressPickerContentState extends State<_RefundAddressPickerContent> { + List _addresses = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final addresses = await SwapService().getRefundAddresses(widget.network); + if (mounted) setState(() => _addresses = addresses); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Refund Addresses', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text(widget.network, style: text.detail?.copyWith(color: colors.textSecondary)), + ), + const SizedBox(height: 24), + if (_addresses.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text('No recent refund addresses', style: text.detail?.copyWith(color: colors.textTertiary)), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _addresses.length, + separatorBuilder: (_, _) => Divider(color: colors.separator, height: 1), + itemBuilder: (_, i) => _addressItem(_addresses[i], colors, text), + ), + ), + ], + ), + ), + ); + } + + Widget _addressItem(String address, AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: () => Navigator.pop(context, address), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + AddressFormattingService.formatAddress(address), + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart new file mode 100644 index 00000000..d1945fd6 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart @@ -0,0 +1,176 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/deposit_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +void showReviewQuoteSheet(BuildContext context, SwapQuote quote, String refundAddress) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => _ReviewQuoteContent(quote: quote, refundAddress: refundAddress), + ); +} + +class _ReviewQuoteContent extends StatelessWidget { + final SwapQuote quote; + final String refundAddress; + const _ReviewQuoteContent({required this.quote, required this.refundAddress}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final swapService = SwapService(); + final fromUsd = quote.fromAmount * swapService.getUsdPrice(quote.fromToken); + final toUsd = quote.toAmount * swapService.getUsdPrice(quote.toToken); + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Review Quote', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 32), + _swapVisual(context, colors, text, fromUsd, toUsd), + const SizedBox(height: 48), + _feeRow('Total fees', '${quote.networkFee.toStringAsFixed(3)} ${quote.fromToken.symbol}', colors, text), + Divider(color: colors.separator, height: 32), + _feeRow( + 'Total Amount', + '${quote.totalAmount.toStringAsFixed(2)} ${quote.fromToken.symbol}', + colors, + text, + highlight: true, + ), + const SizedBox(height: 24), + Text( + 'You could receive up to \$${(quote.fromAmount * quote.slippageTolerance).toStringAsFixed(2)} less based on the ${(quote.slippageTolerance * 100).toStringAsFixed(0)}% slippage you set', + style: text.tiny?.copyWith(color: colors.textSecondary, height: 1.35), + ), + const SizedBox(height: 24), + _confirmButton(context, colors, text), + ], + ), + ), + ); + } + + Widget _swapVisual(BuildContext context, AppColorsV2 colors, AppTextTheme text, double fromUsd, double toUsd) { + final cardWidth = MediaQuery.of(context).size.width / 3; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _tokenCard(quote.fromToken, quote.fromAmount, fromUsd, cardWidth, colors, text), + Icon(Icons.arrow_forward, color: colors.textSecondary, size: 20), + _tokenCard(quote.toToken, quote.toAmount, toUsd, cardWidth, colors, text), + ], + ); + } + + Widget _tokenCard(SwapToken token, double amount, double usd, double width, AppColorsV2 colors, AppTextTheme text) { + final isQu = token.symbol == 'QUAN'; + return Container( + width: width, + height: 111, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: isQu ? colors.accentGreen.withValues(alpha: 0.3) : colors.accentPink.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + token.symbol, + style: text.detail?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + Text(token.network, style: text.tiny?.copyWith(color: colors.textSecondary)), + ], + ), + ], + ), + const SizedBox(height: 6), + Text( + amount.toStringAsFixed(2), + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 0), + Text( + '\$${usd.toStringAsFixed(2)}', + style: text.detail?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _feeRow(String label, String value, AppColorsV2 colors, AppTextTheme text, {bool highlight = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: text.detail?.copyWith(color: colors.textSecondary)), + Text( + value, + style: text.detail?.copyWith( + color: highlight ? colors.textPrimary : colors.textSecondary, + fontWeight: highlight ? FontWeight.w500 : null, + ), + ), + ], + ); + } + + Widget _confirmButton(BuildContext context, AppColorsV2 colors, AppTextTheme text) { + return GestureDetector( + onTap: () async { + final swapService = SwapService(); + final order = await swapService.createSwap(quote); + if (!context.mounted) return; + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => DepositScreen(order: order))); + }, + child: GlassContainer( + asset: GlassContainer.wideAsset, + child: Center( + child: Text( + 'Confirm', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart new file mode 100644 index 00000000..0127650f --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -0,0 +1,488 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/components/back_button.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/refund_address_picker_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/review_quote_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/token_picker_sheet.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class SwapScreen extends StatefulWidget { + const SwapScreen({super.key}); + + @override + State createState() => _SwapScreenState(); +} + +class _SwapScreenState extends State { + static const _smallGlassAsset = 'assets/v2/glass_40.png'; + static const _qrIconAsset = 'assets/v2/swap_qr_code.svg'; + static const _historyIconAsset = 'assets/v2/swap_clock_counter_clockwise.svg'; + static const _swapDirectionIconAsset = 'assets/v2/swap_arrows_down_up.svg'; + + final _swapService = SwapService(); + final _fromController = TextEditingController(); + final _addressController = TextEditingController(); + SwapToken _fromToken = SwapService.availableTokens.first; + double _toAmount = 0; + double _fromUsd = 0; + double _toUsd = 0; + bool _loading = false; + + double get _rate => _swapService.getRate(_fromToken); + String get _rateLabel => '1 QUAN = ${(1 / _rate).toStringAsFixed(4)} ${_fromToken.symbol}'; + + @override + void initState() { + super.initState(); + _fromController.addListener(_recalculate); + } + + @override + void dispose() { + _fromController.dispose(); + _addressController.dispose(); + super.dispose(); + } + + void _recalculate() { + final amount = double.tryParse(_fromController.text) ?? 0; + setState(() { + _toAmount = amount * _rate; + _fromUsd = amount * _swapService.getUsdPrice(_fromToken); + _toUsd = _toAmount * _swapService.getUsdPrice(_swapService.getQuToken()); + }); + } + + bool get _canGetQuote => + _fromController.text.isNotEmpty && + (double.tryParse(_fromController.text) ?? 0) > 0 && + _addressController.text.isNotEmpty; + + Future _getQuote() async { + final amount = double.tryParse(_fromController.text) ?? 0; + if (amount <= 0 || _addressController.text.isEmpty) return; + + setState(() => _loading = true); + try { + final quote = await _swapService.getQuote(fromToken: _fromToken, fromAmount: amount); + if (!mounted) return; + setState(() => _loading = false); + _swapService.addRefundAddress(_fromToken.network, _addressController.text.trim()); + showReviewQuoteSheet(context, quote, _addressController.text); + } catch (e) { + setState(() => _loading = false); + } + } + + void _pickToken() async { + final token = await showTokenPickerSheet(context, _swapService.getFromTokens(), _fromToken); + if (token != null && token != _fromToken) { + setState(() => _fromToken = token); + _recalculate(); + } + } + + void _scanQr() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _QrScanPage( + onScanned: (v) { + _addressController.text = v; + Navigator.pop(context); + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const SizedBox(height: 16), + _header(colors, text), + const SizedBox(height: 64), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fromSection(colors, text), + const SizedBox(height: 32), + _refundAddressSection(colors, text), + const SizedBox(height: 32), + _swapDivider(colors), + const SizedBox(height: 32), + _toSection(colors, text), + const SizedBox(height: 32), + _infoSection(colors, text), + ], + ), + ), + ), + const SizedBox(height: 16), + _quoteButton(colors, text), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _header(AppColorsV2 colors, AppTextTheme text) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const AppBackButton(), + Text('Swap', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), + Icon(Icons.info_outline, color: colors.textPrimary, size: 24), + ], + ); + } + + Widget _fromSection(AppColorsV2 colors, AppTextTheme text) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('From', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.centerLeft, + child: TextField( + controller: _fromController, + style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.bold), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + hintText: '0.00', + hintStyle: text.mediumTitle?.copyWith(color: colors.textTertiary, fontWeight: FontWeight.bold), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: Colors.transparent, + ), + ), + ), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: _pickToken, + child: SizedBox( + width: 119, + child: GlassContainer( + asset: GlassContainer.mediumAsset, + filled: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Container( + width: 25, + height: 25, + decoration: BoxDecoration( + color: colors.accentPink.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fromToken.symbol, + style: text.detail?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + Text( + _fromToken.network, + style: text.tiny?.copyWith(color: colors.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, color: colors.textSecondary, size: 16), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text('\$${_fromUsd.toStringAsFixed(2)}', style: text.detail?.copyWith(color: colors.textSecondary)), + const SizedBox(width: 4), + Icon(Icons.swap_vert, color: colors.textSecondary, size: 12), + ], + ), + ], + ); + } + + Widget _refundAddressSection(AppColorsV2 colors, AppTextTheme text) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Refund Address', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(width: 4), + Icon(Icons.info_outline, color: colors.textSecondary, size: 14), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _addressController, + style: text.smallParagraph?.copyWith(color: colors.textPrimary), + decoration: InputDecoration( + hintText: '${_fromToken.network} Address', + hintStyle: text.smallParagraph?.copyWith(color: colors.textTertiary), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: Colors.transparent, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _scanQr, + child: _smallGlassIconButton(colors: colors, iconAsset: _qrIconAsset), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () async { + final address = await showRefundAddressPickerSheet(context, _fromToken.network); + if (address != null) { + _addressController.text = address; + setState(() {}); + } + }, + child: _smallGlassIconButton(colors: colors, iconAsset: _historyIconAsset), + ), + ], + ), + ), + ], + ); + } + + Widget _swapDivider(AppColorsV2 colors) { + return Row( + children: [ + Expanded(child: Divider(color: colors.separator)), + SizedBox( + width: 40, + height: 40, + child: _smallGlassIconButton(colors: colors, iconAsset: _swapDirectionIconAsset), + ), + Expanded(child: Divider(color: colors.separator)), + ], + ); + } + + Widget _smallGlassIconButton({required AppColorsV2 colors, required String iconAsset}) { + return SizedBox( + width: 40, + height: 40, + child: GlassContainer( + asset: _smallGlassAsset, + child: Center( + child: SvgPicture.asset( + iconAsset, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(colors.textPrimary, BlendMode.srcIn), + ), + ), + ), + ); + } + + Widget _toSection(AppColorsV2 colors, AppTextTheme text) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('To', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + alignment: Alignment.centerLeft, + child: Text( + _toAmount > 0 ? _toAmount.toStringAsFixed(2) : '0.00', + style: text.mediumTitle?.copyWith( + fontWeight: FontWeight.bold, + color: _toAmount > 0 ? colors.textPrimary : colors.textTertiary, + ), + ), + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 119, + height: 56, + child: GlassContainer( + asset: GlassContainer.mediumAsset, + filled: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 25, + height: 25, + decoration: BoxDecoration( + color: colors.accentGreen.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + 'QUAN', + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text('\$${_toUsd.toStringAsFixed(2)}', style: text.detail?.copyWith(color: colors.textSecondary)), + ], + ); + } + + Widget _infoSection(AppColorsV2 colors, AppTextTheme text) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Slippage Tolerance', style: text.detail?.copyWith(color: colors.textSecondary)), + Row( + children: [ + Text('1%', style: text.detail?.copyWith(color: colors.textSecondary)), + const SizedBox(width: 4), + Icon(Icons.settings, color: colors.textSecondary, size: 12), + ], + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Rate', style: text.detail?.copyWith(color: colors.textSecondary)), + Text( + _rateLabel, + style: text.detail?.copyWith(color: colors.textSecondary, fontWeight: FontWeight.w500), + ), + ], + ), + ], + ); + } + + Widget _quoteButton(AppColorsV2 colors, AppTextTheme text) { + final enabled = _canGetQuote && !_loading; + return GestureDetector( + onTap: enabled ? _getQuote : null, + child: Opacity( + opacity: enabled ? 1.0 : 0.4, + child: GlassContainer( + asset: GlassContainer.wideAsset, + child: Center( + child: _loading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(color: colors.textPrimary, strokeWidth: 2), + ) + : Text( + 'Get a Quote', + style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + ), + ), + ), + ); + } +} + +class _QrScanPage extends StatelessWidget { + final ValueChanged onScanned; + const _QrScanPage({required this.onScanned}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + MobileScanner( + onDetect: (capture) { + final code = capture.barcodes.firstOrNull?.rawValue; + if (code != null) onScanned(code); + }, + ), + Positioned( + bottom: 60, + left: 24, + right: 24, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Center( + child: Text('Cancel', style: TextStyle(color: colors.textPrimary, fontSize: 16)), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart new file mode 100644 index 00000000..2ad1da51 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart @@ -0,0 +1,92 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +Future showTokenPickerSheet(BuildContext context, List tokens, SwapToken current) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => _TokenPickerContent(tokens: tokens, current: current), + ); +} + +class _TokenPickerContent extends StatelessWidget { + final List tokens; + final SwapToken current; + const _TokenPickerContent({required this.tokens, required this.current}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Select Token', style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 18)), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 24), + ...tokens.map((token) => _tokenRow(context, token, colors, text)), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _tokenRow(BuildContext context, SwapToken token, AppColorsV2 colors, AppTextTheme text) { + final selected = token == current; + return GestureDetector( + onTap: () => Navigator.pop(context, token), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: colors.accentPink.withValues(alpha: 0.2), shape: BoxShape.circle), + child: Center( + child: Text(token.symbol[0], style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + token.symbol, + style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + ), + Text('${token.name} · ${token.network}', style: text.detail?.copyWith(color: colors.textTertiary)), + ], + ), + ), + if (selected) Icon(Icons.check_circle, color: colors.accentGreen, size: 20), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/welcome/welcome_screen.dart b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart new file mode 100644 index 00000000..0788f304 --- /dev/null +++ b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/v2/components/glass_container.dart'; +import 'package:resonance_network_wallet/v2/screens/create/wallet_ready_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/import/import_wallet_screen.dart'; +import 'package:resonance_network_wallet/v2/components/gradient_background.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class WelcomeScreenV2 extends StatelessWidget { + const WelcomeScreenV2({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Scaffold( + backgroundColor: colors.background, + body: GradientBackground( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 40), + Row( + children: [ + // const SizedBox(width: 6), + Image.asset('assets/v2/quantus_white_logo.png', height: 32), + ], + ), + const Spacer(), + Text( + 'Quantum Secure\nCrypto', + textAlign: TextAlign.left, + style: text.largeTitle?.copyWith(fontSize: 32, height: 1.35, color: Colors.white), + ), + const SizedBox(height: 64), + GlassContainer( + asset: GlassContainer.wideAsset, + filled: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'create_wallet'), + builder: (_) => const WalletReadyScreenV2(), + ), + ), + child: Center( + child: Text( + 'Create New Wallet', + style: text.paragraph?.copyWith(fontWeight: FontWeight.w500, color: colors.textPrimary), + ), + ), + ), + const SizedBox(height: 32), + GlassContainer( + asset: GlassContainer.wideAsset, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: 'import_wallet'), + builder: (_) => const ImportWalletScreenV2(), + ), + ), + child: Center( + child: Text( + 'Import Existing Wallet', + style: text.paragraph?.copyWith(fontWeight: FontWeight.w500, color: colors.textPrimary), + ), + ), + ), + const SizedBox(height: 60), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/theme/app_colors.dart b/mobile-app/lib/v2/theme/app_colors.dart new file mode 100644 index 00000000..141feb92 --- /dev/null +++ b/mobile-app/lib/v2/theme/app_colors.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; + +@immutable +class AppColorsV2 extends ThemeExtension { + // Backgrounds + final Color background; + final Color backgroundAlt; + + // Surfaces + final Color surface; + final Color surfaceGlass; + final Color surfaceCard; + + // Text + final Color textPrimary; + final Color textSecondary; + final Color textTertiary; + final Color textError; + + // Accents + final Color accentGreen; + final Color accentPink; + final Color checksum; + + // Semantic + final Color error; + final Color danger; + final Color success; + + // Glow & Gradients + final Color backgroundGlow; + final List buttonPrimaryGradient; + + // UI elements + final Color separator; + final Color txItemSeparator; + final Color border; + final Color buttonDisabled; + final Color skeletonBase; + final Color skeletonHighlight; + + // Account tags + final Color tagGuardian; + final Color tagEntrusted; + final Color tagHighSecurity; + + const AppColorsV2({ + required this.background, + required this.backgroundAlt, + required this.surface, + required this.surfaceGlass, + required this.surfaceCard, + required this.textPrimary, + required this.textSecondary, + required this.textTertiary, + required this.textError, + required this.accentGreen, + required this.accentPink, + required this.checksum, + required this.error, + required this.danger, + required this.success, + required this.backgroundGlow, + required this.buttonPrimaryGradient, + required this.separator, + required this.txItemSeparator, + required this.border, + required this.buttonDisabled, + required this.skeletonBase, + required this.skeletonHighlight, + required this.tagGuardian, + required this.tagEntrusted, + required this.tagHighSecurity, + }); + + const AppColorsV2.dark() + : this( + background: const Color(0xFF141414), + backgroundAlt: const Color(0xFF1F1F1F), + surface: const Color(0xFF292929), + surfaceGlass: const Color(0x1AFFFFFF), + surfaceCard: const Color(0x0FFFFFFF), + textPrimary: const Color(0xFFFFFFFF), + textSecondary: const Color(0x80FFFFFF), + textTertiary: const Color(0x52FFFFFF), + textError: const Color(0xFFFF5252), + accentGreen: const Color(0xFF34C759), + accentPink: const Color(0xFFED4CCE), + checksum: const Color(0xFF4CEDE7), + error: const Color(0xFFFF2D54), + danger: const Color(0xFFFF1F45), + success: const Color(0xFF1FFFA7), + backgroundGlow: const Color(0xFFFFFFFF), + buttonPrimaryGradient: const [Color(0xFF0000FF), Color(0xFFED4CCE)], + separator: const Color(0x1AFFFFFF), + txItemSeparator: const Color(0x05FFFFFF), + border: const Color(0x33FFFFFF), + buttonDisabled: const Color(0xFF3D3C44), + skeletonBase: const Color(0xFF3D3C44), + skeletonHighlight: const Color(0xFF5A5A5A), + tagGuardian: const Color(0xFF9747FF), + tagEntrusted: const Color(0xFFFFD541), + tagHighSecurity: const Color(0xFF4CEDE7), + ); + + @override + AppColorsV2 copyWith({ + Color? background, + Color? backgroundAlt, + Color? surface, + Color? surfaceGlass, + Color? surfaceCard, + Color? textPrimary, + Color? textSecondary, + Color? textTertiary, + Color? textError, + Color? accentGreen, + Color? accentPink, + Color? checksum, + Color? error, + Color? danger, + Color? success, + Color? backgroundGlow, + List? buttonPrimaryGradient, + Color? separator, + Color? txItemSeparator, + Color? border, + Color? buttonDisabled, + Color? skeletonBase, + Color? skeletonHighlight, + Color? tagGuardian, + Color? tagEntrusted, + Color? tagHighSecurity, + }) { + return AppColorsV2( + background: background ?? this.background, + backgroundAlt: backgroundAlt ?? this.backgroundAlt, + surface: surface ?? this.surface, + surfaceGlass: surfaceGlass ?? this.surfaceGlass, + surfaceCard: surfaceCard ?? this.surfaceCard, + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + textTertiary: textTertiary ?? this.textTertiary, + textError: textError ?? this.textError, + accentGreen: accentGreen ?? this.accentGreen, + accentPink: accentPink ?? this.accentPink, + checksum: checksum ?? this.checksum, + error: error ?? this.error, + danger: danger ?? this.danger, + success: success ?? this.success, + backgroundGlow: backgroundGlow ?? this.backgroundGlow, + buttonPrimaryGradient: buttonPrimaryGradient ?? this.buttonPrimaryGradient, + separator: separator ?? this.separator, + txItemSeparator: txItemSeparator ?? this.txItemSeparator, + border: border ?? this.border, + buttonDisabled: buttonDisabled ?? this.buttonDisabled, + skeletonBase: skeletonBase ?? this.skeletonBase, + skeletonHighlight: skeletonHighlight ?? this.skeletonHighlight, + tagGuardian: tagGuardian ?? this.tagGuardian, + tagEntrusted: tagEntrusted ?? this.tagEntrusted, + tagHighSecurity: tagHighSecurity ?? this.tagHighSecurity, + ); + } + + @override + AppColorsV2 lerp(AppColorsV2? other, double t) { + if (other is! AppColorsV2) return this; + return AppColorsV2( + background: Color.lerp(background, other.background, t) ?? background, + backgroundAlt: Color.lerp(backgroundAlt, other.backgroundAlt, t) ?? backgroundAlt, + surface: Color.lerp(surface, other.surface, t) ?? surface, + surfaceGlass: Color.lerp(surfaceGlass, other.surfaceGlass, t) ?? surfaceGlass, + surfaceCard: Color.lerp(surfaceCard, other.surfaceCard, t) ?? surfaceCard, + textPrimary: Color.lerp(textPrimary, other.textPrimary, t) ?? textPrimary, + textSecondary: Color.lerp(textSecondary, other.textSecondary, t) ?? textSecondary, + textTertiary: Color.lerp(textTertiary, other.textTertiary, t) ?? textTertiary, + textError: Color.lerp(textError, other.textError, t) ?? textError, + accentGreen: Color.lerp(accentGreen, other.accentGreen, t) ?? accentGreen, + accentPink: Color.lerp(accentPink, other.accentPink, t) ?? accentPink, + checksum: Color.lerp(checksum, other.checksum, t) ?? checksum, + error: Color.lerp(error, other.error, t) ?? error, + danger: Color.lerp(danger, other.danger, t) ?? danger, + success: Color.lerp(success, other.success, t) ?? success, + backgroundGlow: Color.lerp(backgroundGlow, other.backgroundGlow, t) ?? backgroundGlow, + buttonPrimaryGradient: other.buttonPrimaryGradient, + separator: Color.lerp(separator, other.separator, t) ?? separator, + txItemSeparator: Color.lerp(txItemSeparator, other.txItemSeparator, t) ?? txItemSeparator, + border: Color.lerp(border, other.border, t) ?? border, + buttonDisabled: Color.lerp(buttonDisabled, other.buttonDisabled, t) ?? buttonDisabled, + skeletonBase: Color.lerp(skeletonBase, other.skeletonBase, t) ?? skeletonBase, + skeletonHighlight: Color.lerp(skeletonHighlight, other.skeletonHighlight, t) ?? skeletonHighlight, + tagGuardian: Color.lerp(tagGuardian, other.tagGuardian, t) ?? tagGuardian, + tagEntrusted: Color.lerp(tagEntrusted, other.tagEntrusted, t) ?? tagEntrusted, + tagHighSecurity: Color.lerp(tagHighSecurity, other.tagHighSecurity, t) ?? tagHighSecurity, + ); + } +} + +extension AppColorsV2Extension on BuildContext { + AppColorsV2 get colors => Theme.of(this).extension()!; +} diff --git a/mobile-app/lib/v2/theme/app_spacing.dart b/mobile-app/lib/v2/theme/app_spacing.dart new file mode 100644 index 00000000..ce7c34de --- /dev/null +++ b/mobile-app/lib/v2/theme/app_spacing.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; + +@immutable +class AppSizeTheme extends ThemeExtension { + final double logoHeight; + final double mainMenuHeight; + final double mainMenuWidth; + final double mainMenuIconSize; + final double navbarHeight; + final double navbarItemHeight; + final double navbarItemWidth; + final double navbarIconWidth; + final double floatingBtnHeight; + final double floatingBtnWidth; + final double settingMenuIconSize; + final double settingMenuShareIconSize; + final double accountListItemHeight; + final double accountListItemLogoWidth; + final double appbarIconSize; + final double sendOverlayContainerWidth; + final double overlayCloseIconSize; + final double mnemonicCellDesiredHeight; + final double txListItemIconWidth; + final double txDetailsIconHeight; + final double txDetailsIconWidth; + final double copyIconSize; + final double pasteIconSize; + final double timePickerSubtitleWidth; + final double bottomButtonSpacing; + final double buttonsHorizontalSpacing; + final double infoSheetTitleIcon; + + final double screenPadding; + final double cardPadding; + final double sectionGap; + final double itemGap; + final double sectionHeaderToContent; + + final double radiusFull; + final double radiusCard; + final double radiusSmall; + + const AppSizeTheme({ + required this.logoHeight, + required this.mainMenuHeight, + required this.mainMenuWidth, + required this.mainMenuIconSize, + required this.navbarHeight, + required this.navbarItemHeight, + required this.navbarItemWidth, + required this.navbarIconWidth, + required this.floatingBtnHeight, + required this.floatingBtnWidth, + required this.settingMenuIconSize, + required this.settingMenuShareIconSize, + required this.accountListItemHeight, + required this.accountListItemLogoWidth, + required this.appbarIconSize, + required this.sendOverlayContainerWidth, + required this.overlayCloseIconSize, + required this.mnemonicCellDesiredHeight, + required this.txListItemIconWidth, + required this.txDetailsIconHeight, + required this.txDetailsIconWidth, + required this.copyIconSize, + required this.pasteIconSize, + required this.timePickerSubtitleWidth, + required this.bottomButtonSpacing, + required this.buttonsHorizontalSpacing, + required this.infoSheetTitleIcon, + required this.screenPadding, + required this.cardPadding, + required this.sectionGap, + required this.itemGap, + required this.sectionHeaderToContent, + required this.radiusFull, + required this.radiusCard, + required this.radiusSmall, + }); + + const AppSizeTheme.defaultTheme() + : this( + logoHeight: 158.0, + mainMenuHeight: 20, + mainMenuWidth: 20, + mainMenuIconSize: 21.0, + navbarHeight: 67.0, + navbarItemHeight: 32, + navbarItemWidth: 40, + navbarIconWidth: 23, + floatingBtnHeight: 49.0, + floatingBtnWidth: 52.0, + settingMenuIconSize: 11.0, + settingMenuShareIconSize: 20.0, + accountListItemHeight: 110.0, + accountListItemLogoWidth: 36.0, + appbarIconSize: 18.0, + sendOverlayContainerWidth: double.infinity, + overlayCloseIconSize: 24.0, + mnemonicCellDesiredHeight: 31.0, + txListItemIconWidth: 21.0, + txDetailsIconHeight: 43.0, + txDetailsIconWidth: 51.0, + copyIconSize: 20.0, + pasteIconSize: 18.0, + timePickerSubtitleWidth: 249, + bottomButtonSpacing: 16, + buttonsHorizontalSpacing: 28, + infoSheetTitleIcon: 25, + screenPadding: 24.0, + cardPadding: 20.0, + sectionGap: 40.0, + itemGap: 12.0, + sectionHeaderToContent: 36.0, + radiusFull: 30.0, + radiusCard: 14.0, + radiusSmall: 6.0, + ); + + const AppSizeTheme.iPad() + : this( + logoHeight: 180.0, + mainMenuHeight: 30, + mainMenuWidth: 30, + mainMenuIconSize: 29.0, + navbarHeight: 87.0, + navbarItemHeight: 40, + navbarItemWidth: 48, + navbarIconWidth: 32, + floatingBtnHeight: 74.0, + floatingBtnWidth: 77.0, + settingMenuIconSize: 16.0, + settingMenuShareIconSize: 24.0, + accountListItemHeight: 130.0, + accountListItemLogoWidth: 48.0, + appbarIconSize: 20.0, + sendOverlayContainerWidth: 510.0, + overlayCloseIconSize: 28.0, + mnemonicCellDesiredHeight: 80.0, + txListItemIconWidth: 32.0, + txDetailsIconHeight: 82.0, + txDetailsIconWidth: 91.0, + copyIconSize: 28.0, + pasteIconSize: 24.0, + timePickerSubtitleWidth: 400, + bottomButtonSpacing: 16, + buttonsHorizontalSpacing: 28, + infoSheetTitleIcon: 28, + screenPadding: 32.0, + cardPadding: 24.0, + sectionGap: 48.0, + itemGap: 16.0, + sectionHeaderToContent: 40.0, + radiusFull: 30.0, + radiusCard: 14.0, + radiusSmall: 6.0, + ); + + @override + AppSizeTheme copyWith({ + double? logoHeight, + double? mainMenuHeight, + double? mainMenuWidth, + double? mainMenuIconSize, + double? navbarHeight, + double? navbarItemHeight, + double? navbarItemWidth, + double? navbarIconWidth, + double? floatingBtnHeight, + double? floatingBtnWidth, + double? settingMenuIconSize, + double? settingMenuShareIconSize, + double? accountListItemHeight, + double? accountListItemLogoWidth, + double? appbarIconSize, + double? sendOverlayContainerWidth, + double? overlayCloseIconSize, + double? mnemonicCellDesiredHeight, + double? txListItemIconWidth, + double? txDetailsIconHeight, + double? txDetailsIconWidth, + double? copyIconSize, + double? pasteIconSize, + double? timePickerSubtitleWidth, + double? bottomButtonSpacing, + double? buttonsHorizontalSpacing, + double? infoSheetTitleIcon, + double? screenPadding, + double? cardPadding, + double? sectionGap, + double? itemGap, + double? sectionHeaderToContent, + double? radiusFull, + double? radiusCard, + double? radiusSmall, + }) { + return AppSizeTheme( + logoHeight: logoHeight ?? this.logoHeight, + mainMenuHeight: mainMenuHeight ?? this.mainMenuHeight, + mainMenuWidth: mainMenuWidth ?? this.mainMenuWidth, + mainMenuIconSize: mainMenuIconSize ?? this.mainMenuIconSize, + navbarHeight: navbarHeight ?? this.navbarHeight, + navbarItemHeight: navbarItemHeight ?? this.navbarItemHeight, + navbarItemWidth: navbarItemWidth ?? this.navbarItemWidth, + navbarIconWidth: navbarIconWidth ?? this.navbarIconWidth, + floatingBtnHeight: floatingBtnHeight ?? this.floatingBtnHeight, + floatingBtnWidth: floatingBtnWidth ?? this.floatingBtnWidth, + settingMenuIconSize: settingMenuIconSize ?? this.settingMenuIconSize, + settingMenuShareIconSize: settingMenuShareIconSize ?? this.settingMenuShareIconSize, + accountListItemHeight: accountListItemHeight ?? this.accountListItemHeight, + accountListItemLogoWidth: accountListItemLogoWidth ?? this.accountListItemLogoWidth, + appbarIconSize: appbarIconSize ?? this.appbarIconSize, + sendOverlayContainerWidth: sendOverlayContainerWidth ?? this.sendOverlayContainerWidth, + overlayCloseIconSize: overlayCloseIconSize ?? this.overlayCloseIconSize, + mnemonicCellDesiredHeight: mnemonicCellDesiredHeight ?? this.mnemonicCellDesiredHeight, + txListItemIconWidth: txListItemIconWidth ?? this.txListItemIconWidth, + txDetailsIconHeight: txDetailsIconHeight ?? this.txDetailsIconHeight, + txDetailsIconWidth: txDetailsIconWidth ?? this.txDetailsIconWidth, + copyIconSize: copyIconSize ?? this.copyIconSize, + pasteIconSize: pasteIconSize ?? this.pasteIconSize, + timePickerSubtitleWidth: timePickerSubtitleWidth ?? this.timePickerSubtitleWidth, + bottomButtonSpacing: bottomButtonSpacing ?? this.bottomButtonSpacing, + buttonsHorizontalSpacing: buttonsHorizontalSpacing ?? this.buttonsHorizontalSpacing, + infoSheetTitleIcon: infoSheetTitleIcon ?? this.infoSheetTitleIcon, + screenPadding: screenPadding ?? this.screenPadding, + cardPadding: cardPadding ?? this.cardPadding, + sectionGap: sectionGap ?? this.sectionGap, + itemGap: itemGap ?? this.itemGap, + sectionHeaderToContent: sectionHeaderToContent ?? this.sectionHeaderToContent, + radiusFull: radiusFull ?? this.radiusFull, + radiusCard: radiusCard ?? this.radiusCard, + radiusSmall: radiusSmall ?? this.radiusSmall, + ); + } + + @override + AppSizeTheme lerp(AppSizeTheme? other, double t) { + if (other is! AppSizeTheme) return this; + double l(double a, double b) => a + (b - a) * t; + return AppSizeTheme( + logoHeight: l(logoHeight, other.logoHeight), + mainMenuHeight: l(mainMenuHeight, other.mainMenuHeight), + mainMenuWidth: l(mainMenuWidth, other.mainMenuWidth), + mainMenuIconSize: l(mainMenuIconSize, other.mainMenuIconSize), + navbarHeight: l(navbarHeight, other.navbarHeight), + navbarItemHeight: l(navbarItemHeight, other.navbarItemHeight), + navbarItemWidth: l(navbarItemWidth, other.navbarItemWidth), + navbarIconWidth: l(navbarIconWidth, other.navbarIconWidth), + floatingBtnHeight: l(floatingBtnHeight, other.floatingBtnHeight), + floatingBtnWidth: l(floatingBtnWidth, other.floatingBtnWidth), + settingMenuIconSize: l(settingMenuIconSize, other.settingMenuIconSize), + settingMenuShareIconSize: l(settingMenuShareIconSize, other.settingMenuShareIconSize), + accountListItemHeight: l(accountListItemHeight, other.accountListItemHeight), + accountListItemLogoWidth: l(accountListItemLogoWidth, other.accountListItemLogoWidth), + appbarIconSize: l(appbarIconSize, other.appbarIconSize), + sendOverlayContainerWidth: l(sendOverlayContainerWidth, other.sendOverlayContainerWidth), + overlayCloseIconSize: l(overlayCloseIconSize, other.overlayCloseIconSize), + mnemonicCellDesiredHeight: l(mnemonicCellDesiredHeight, other.mnemonicCellDesiredHeight), + txListItemIconWidth: l(txListItemIconWidth, other.txListItemIconWidth), + txDetailsIconHeight: l(txDetailsIconHeight, other.txDetailsIconHeight), + txDetailsIconWidth: l(txDetailsIconWidth, other.txDetailsIconWidth), + copyIconSize: l(copyIconSize, other.copyIconSize), + pasteIconSize: l(pasteIconSize, other.pasteIconSize), + timePickerSubtitleWidth: l(timePickerSubtitleWidth, other.timePickerSubtitleWidth), + bottomButtonSpacing: l(bottomButtonSpacing, other.bottomButtonSpacing), + buttonsHorizontalSpacing: l(buttonsHorizontalSpacing, other.buttonsHorizontalSpacing), + infoSheetTitleIcon: l(infoSheetTitleIcon, other.infoSheetTitleIcon), + screenPadding: l(screenPadding, other.screenPadding), + cardPadding: l(cardPadding, other.cardPadding), + sectionGap: l(sectionGap, other.sectionGap), + itemGap: l(itemGap, other.itemGap), + sectionHeaderToContent: l(sectionHeaderToContent, other.sectionHeaderToContent), + radiusFull: l(radiusFull, other.radiusFull), + radiusCard: l(radiusCard, other.radiusCard), + radiusSmall: l(radiusSmall, other.radiusSmall), + ); + } +} + +extension AppSizeThemeExtension on BuildContext { + AppSizeTheme get themeSize => Theme.of(this).extension()!; +} diff --git a/mobile-app/lib/v2/theme/app_text_styles.dart b/mobile-app/lib/v2/theme/app_text_styles.dart new file mode 100644 index 00000000..2771ad06 --- /dev/null +++ b/mobile-app/lib/v2/theme/app_text_styles.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +const _fontFamily = 'Inter'; + +@immutable +class AppTextTheme extends ThemeExtension { + final TextStyle? lockTitle; + final TextStyle? extraLargeTitle; + final TextStyle? largeTitle; + final TextStyle? mediumTitle; + final TextStyle? smallTitle; + final TextStyle? paragraph; + final TextStyle? smallParagraph; + final TextStyle? largeTag; + final TextStyle? tag; + final TextStyle? timer; + final TextStyle? detail; + final TextStyle? tiny; + + const AppTextTheme({ + this.lockTitle, + this.extraLargeTitle, + this.largeTitle, + this.mediumTitle, + this.smallTitle, + this.paragraph, + this.smallParagraph, + this.largeTag, + this.tag, + this.timer, + this.detail, + this.tiny, + }); + + const AppTextTheme.defaultTheme() + : this( + lockTitle: const TextStyle(fontSize: 24, fontFamily: _fontFamily), + extraLargeTitle: const TextStyle(fontSize: 40, fontFamily: _fontFamily, fontWeight: FontWeight.w600), + largeTitle: const TextStyle(fontSize: 30, fontFamily: _fontFamily, fontWeight: FontWeight.w300), + mediumTitle: const TextStyle(fontSize: 24, fontFamily: _fontFamily, fontWeight: FontWeight.w500), + smallTitle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500, fontFamily: _fontFamily), + paragraph: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + smallParagraph: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + largeTag: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + tag: const TextStyle(fontSize: 12, fontWeight: FontWeight.w300, fontFamily: _fontFamily), + timer: const TextStyle(fontSize: 28, fontWeight: FontWeight.w600, fontFamily: _fontFamily), + detail: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + tiny: const TextStyle(fontSize: 11, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + ); + + const AppTextTheme.iPad() + : this( + lockTitle: const TextStyle(color: Colors.white, fontSize: 28, fontFamily: _fontFamily), + extraLargeTitle: const TextStyle(fontSize: 52, fontFamily: _fontFamily, fontWeight: FontWeight.w600), + largeTitle: const TextStyle(fontSize: 36, fontFamily: _fontFamily, fontWeight: FontWeight.w300), + mediumTitle: const TextStyle(fontSize: 28, fontFamily: _fontFamily, fontWeight: FontWeight.w400), + smallTitle: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500, fontFamily: _fontFamily), + paragraph: const TextStyle(fontSize: 20, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + smallParagraph: const TextStyle(fontSize: 18, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + largeTag: const TextStyle(fontSize: 24, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + tag: const TextStyle(fontSize: 16, fontWeight: FontWeight.w300, fontFamily: _fontFamily), + timer: const TextStyle(fontSize: 36, fontWeight: FontWeight.w600, fontFamily: _fontFamily), + detail: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + tiny: const TextStyle(fontSize: 15, fontWeight: FontWeight.w400, fontFamily: _fontFamily), + ); + + @override + AppTextTheme copyWith({ + TextStyle? lockTitle, + TextStyle? extraLargeTitle, + TextStyle? largeTitle, + TextStyle? mediumTitle, + TextStyle? smallTitle, + TextStyle? paragraph, + TextStyle? smallParagraph, + TextStyle? largeTag, + TextStyle? tag, + TextStyle? timer, + TextStyle? detail, + TextStyle? tiny, + }) { + return AppTextTheme( + lockTitle: lockTitle ?? this.lockTitle, + extraLargeTitle: extraLargeTitle ?? this.extraLargeTitle, + largeTitle: largeTitle ?? this.largeTitle, + mediumTitle: mediumTitle ?? this.mediumTitle, + smallTitle: smallTitle ?? this.smallTitle, + paragraph: paragraph ?? this.paragraph, + smallParagraph: smallParagraph ?? this.smallParagraph, + largeTag: largeTag ?? this.largeTag, + tag: tag ?? this.tag, + timer: timer ?? this.timer, + detail: detail ?? this.detail, + tiny: tiny ?? this.tiny, + ); + } + + @override + AppTextTheme lerp(AppTextTheme? other, double t) { + if (other is! AppTextTheme) return this; + return AppTextTheme( + lockTitle: TextStyle.lerp(lockTitle, other.lockTitle, t), + extraLargeTitle: TextStyle.lerp(extraLargeTitle, other.extraLargeTitle, t), + largeTitle: TextStyle.lerp(largeTitle, other.largeTitle, t), + mediumTitle: TextStyle.lerp(mediumTitle, other.mediumTitle, t), + smallTitle: TextStyle.lerp(smallTitle, other.smallTitle, t), + paragraph: TextStyle.lerp(paragraph, other.paragraph, t), + smallParagraph: TextStyle.lerp(smallParagraph, other.smallParagraph, t), + largeTag: TextStyle.lerp(largeTag, other.largeTag, t), + tag: TextStyle.lerp(tag, other.tag, t), + timer: TextStyle.lerp(timer, other.timer, t), + detail: TextStyle.lerp(detail, other.detail, t), + tiny: TextStyle.lerp(tiny, other.tiny, t), + ); + } +} + +extension AppTextThemeExtension on BuildContext { + AppTextTheme get themeText => Theme.of(this).extension()!; +} diff --git a/mobile-app/lib/v2/theme/app_theme.dart b/mobile-app/lib/v2/theme/app_theme.dart new file mode 100644 index 00000000..fd403b05 --- /dev/null +++ b/mobile-app/lib/v2/theme/app_theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart' as v1_colors; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart' as v1_text; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart' as v1_size; + +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_spacing.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class AppTheme { + static ThemeData darkTheme(BuildContext context) { + final isTablet = context.isTablet; + final colors = const AppColorsV2.dark(); + + return ThemeData( + scaffoldBackgroundColor: colors.background, + cardColor: colors.surface, + colorScheme: ColorScheme.dark(surface: colors.surface, error: colors.error), + appBarTheme: AppBarTheme(backgroundColor: colors.surface, elevation: 0), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + ), + textButtonTheme: TextButtonThemeData(style: TextButton.styleFrom(foregroundColor: colors.textPrimary)), + inputDecorationTheme: InputDecorationTheme( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: true, + fillColor: colors.surface, + ), + extensions: [ + colors, + isTablet ? const AppTextTheme.iPad() : const AppTextTheme.defaultTheme(), + isTablet ? const AppSizeTheme.iPad() : const AppSizeTheme.defaultTheme(), + // v1 compat: keeps existing screens working until migrated + const v1_colors.AppColorsTheme.dark(), + isTablet ? const v1_text.AppTextTheme.iPad() : const v1_text.AppTextTheme.defaultTheme(), + isTablet ? const v1_size.AppSizeTheme.iPad() : const v1_size.AppSizeTheme.defaultTheme(), + ], + ); + } +} diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index e42d4218..b91711bb 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -2,7 +2,7 @@ name: resonance_network_wallet description: A Flutter wallet for the Quantus blockchain. publish_to: "none" -version: 1.1.5+67 +version: 1.2.0+70 environment: sdk: ">=3.8.0 <4.0.0" @@ -57,6 +57,9 @@ dependencies: timezone: ^0.10.1 flutter_timezone: ^5.0.1 collection: ^1.19.1 + glass_kit: ^4.0.2 + firebase_core: ^4.4.0 + firebase_messaging: ^16.1.1 dev_dependencies: flutter_test: @@ -101,6 +104,29 @@ flutter: - assets/high_security/big_red_button_icon.png - assets/high_security/intercept_icon.svg - assets/quests/quests_top_logo.png + - assets/v2/green_checkmark.png + - assets/v2/pin_number_background.png + - assets/v2/caret_left.svg + - assets/v2/send_button.png + - assets/v2/receive_button.png + - assets/v2/swap_button.png + - assets/v2/glass_border_bg.png + - assets/v2/glass_medium_button_bg.png + - assets/v2/glass_medium_clear.png + - assets/v2/glass_medium_clear_small.png + - assets/v2/glass_button_wide_340_bg.png + - assets/v2/glass_circle_icon_button_bg.png + - assets/v2/glass_104_x_80.png + - assets/v2/glass_40.png + - assets/v2/action_receive.svg + - assets/v2/action_send.svg + - assets/v2/action_swap.svg + - assets/v2/swap_arrows_down_up.svg + - assets/v2/swap_clock_counter_clockwise.svg + - assets/v2/swap_qr_code.svg + - assets/v2/quantus_white_logo.png + + fonts: - family: Fira Code @@ -115,6 +141,18 @@ flutter: weight: 600 - asset: assets/fonts/FiraCode-Bold.ttf weight: 700 + - family: Inter + fonts: + - asset: assets/fonts/Inter-Light.ttf + weight: 300 + - asset: assets/fonts/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter-Bold.ttf + weight: 700 # Add flutter_native_splash configuration section flutter_native_splash: diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 8de791c5..e258c28a 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -57,7 +57,9 @@ export 'src/services/recovery_service.dart'; export 'src/services/reversible_transfers_service.dart'; export 'src/services/settings_service.dart'; export 'src/services/substrate_service.dart'; +export 'src/services/swap_service.dart'; export 'src/services/taskmaster_service.dart'; +export 'src/services/senoti_service.dart'; export 'src/extensions/account_extension.dart'; export 'src/quantus_signing_payload.dart'; export 'src/quantus_payload_parser.dart'; diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index a6014055..131d070e 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -2,13 +2,14 @@ class AppConstants { static const globalDebug = false; static const String appName = 'Quantus Wallet'; - static const String tokenSymbol = 'QU'; // fetch this from chain eventually + static const String tokenSymbol = 'QUAN'; // fetch this from chain eventually static const String shareUrl = 'https://linktr.ee/quantusnetwork'; static const String websiteBaseUrl = 'https://www.quantus.com'; // static const List rpcEndpoints = ['ws://127.0.0.1:9944']; // local testing // static const List graphQlEndpoints = ['http://127.0.0.1:4350']; // local testing + static const stillOnTestnet = true; static const List rpcEndpoints = [ 'https://a1-dirac.quantus.cat', 'https://a2-dirac.quantus.cat', @@ -22,8 +23,10 @@ class AppConstants { // static const String taskMasterEndpoint = 'http://localhost:3000/api'; static const String taskMasterEndpoint = 'https://quests.quantus.com/api'; + static const String senotiEndpoint = 'http://localhost:3100/api'; + static const String explorerEndpoint = 'https://explorer.quantus.com'; - static const String helpAndSupportUrl = 'https://t.me/quantustechsupport'; + static const String helpAndSupportUrl = 'https://t.me/c/quantusnetwork/2457'; static const String termsOfServiceUrl = 'https://www.quantus.com/terms-and-privacy'; static const String tutorialsAndGuidesUrl = 'https://github.com/Quantus-Network/chain'; static const String shillQuestsPageUrl = 'https://www.quantus.com/quests/shill'; @@ -31,10 +34,6 @@ class AppConstants { static const String communityUrl = 'https://t.me/quantusnetwork'; static const String faucetBotUrl = 'https://t.me/QuantusFaucetBot'; - // Old Resonance chain endpoints - previous chain - static const String oldResonanceRpcEndpoint = 'wss://a.t.res.fm:443'; - static const String odlGraphQlEndpoint = 'https://gql.res.fm'; - // Development accounts static const String crystalAlice = '//Crystal Alice'; static const String crystalBob = '//Crystal Bob'; diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index 40debb76..bbfd30f5 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -14,7 +14,7 @@ class NumberFormattingService { /// Example: 1234500000000 -> "1.2345" (with maxDecimals = 4) String formatBalance( BigInt balance, { - int maxDecimals = 4, + int maxDecimals = 2, bool addThousandsSeparators = true, bool addSymbol = false, }) { diff --git a/quantus_sdk/lib/src/services/senoti_service.dart b/quantus_sdk/lib/src/services/senoti_service.dart new file mode 100644 index 00000000..dc8ffe17 --- /dev/null +++ b/quantus_sdk/lib/src/services/senoti_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:convert/convert.dart' as convert_hex; +import 'package:http/http.dart' as http; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; + +class SenotiAuthClient { + final String senotiEndpointUrl; + final http.Client _client; + + SenotiAuthClient(this.senotiEndpointUrl, {http.Client? client}) : _client = client ?? http.Client(); + + Future> requestChallenge() async { + final r = await _client.get( + Uri.parse('$senotiEndpointUrl/auth/request-challenge'), + headers: {'content-type': 'application/json'}, + ); + if (r.statusCode != 200) { + throw Exception('request-challenge failed: ${r.statusCode} ${r.body}'); + } + final j = jsonDecode(r.body) as Map; + return {'temp_session_id': j['temp_session_id'] as String, 'challenge': j['challenge'] as String}; + } + + Future registerDevice({ + required String ss58Address, + required String publicKeyHex, + required Future Function(List messageBytes) signHex, + required String token, + required String platform, + }) async { + final ch = await requestChallenge(); + final msg = + 'device-registrar:device-registration:1|challenge=${ch['challenge']}|address=$ss58Address|platform=$platform|token=$token'; + final sigHex = await signHex(utf8.encode(msg)); + final r = await _client.post( + Uri.parse('$senotiEndpointUrl/devices'), + headers: {'content-type': 'application/json'}, + body: jsonEncode({ + 'temp_session_id': ch['temp_session_id']!, + 'address': ss58Address, + 'public_key': publicKeyHex, + 'signature': sigHex, + }), + ); + if (r.statusCode != 202) { + throw Exception('verify failed: ${r.statusCode}'); + } + } +} + +// Senoti service singleton +class SenotiService { + static final SenotiService _instance = SenotiService._internal(); + factory SenotiService() => _instance; + SenotiService._internal(); + + final SettingsService _settingsService = SettingsService(); + final HdWalletService _hd = HdWalletService(); + + SenotiAuthClient get _client => SenotiAuthClient(AppConstants.senotiEndpoint); + + Future registerDevice(String token, String platform) async { + final mnemonic = await _settingsService.getMnemonic(0); + if (mnemonic == null) { + throw Exception('Mnemonic not found.'); + } + final keypair = _hd.keyPairAtIndex(mnemonic, 0); + final ss58Address = keypair.ss58Address; + final publicKeyHex = convert_hex.hex.encode(keypair.publicKey); + + Future signHex(List messageBytes) async { + final sig = crypto.signMessage(keypair: keypair, message: messageBytes); + return convert_hex.hex.encode(sig); + } + + await _client.registerDevice( + ss58Address: ss58Address, + publicKeyHex: publicKeyHex, + signHex: signHex, + token: token, + platform: platform, + ); + } +} diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 8fa58ce4..f9c04416 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -24,6 +24,7 @@ class SettingsService { static const String _activeAccountIndexKey = 'active_account_index'; static const String _activeAccountIdKey = 'active_account_id'; static const String _activeDisplayAccountKey = 'active_display_account'; + static const String _balanceHiddenKey = 'balance_hidden'; // Local authentication keys static const String _isLocalAuthEnabledKey = 'is_local_auth_enabled'; @@ -264,7 +265,34 @@ class SettingsService { return await _secureStorage.read(key: getMnemonicKey(walletIndex)); } - // Reversible Time Settings + // PIN Code Settings + Future setPin(String pin) async { + await _secureStorage.write(key: 'wallet_pin', value: pin); + } + + Future getPin() async { + return await _secureStorage.read(key: 'wallet_pin'); + } + + Future hasPin() async { + final pin = await _secureStorage.read(key: 'wallet_pin'); + return pin != null && pin.isNotEmpty; + } + + Future verifyPin(String pin) async { + final stored = await _secureStorage.read(key: 'wallet_pin'); + return stored == pin; + } + + // Reversible Transaction Settings + Future setReversibleEnabled(bool enabled) async { + await _prefs.setBool('reversible_enabled', enabled); + } + + bool isReversibleEnabled() { + return _prefs.getBool('reversible_enabled') ?? false; + } + Future setReversibleTimeSeconds(int seconds) async { await _prefs.setInt('reversible_time_seconds', seconds); } @@ -273,6 +301,15 @@ class SettingsService { return _prefs.getInt('reversible_time_seconds'); } + // Balance Hidden Settings + Future setBalanceHidden(bool hidden) async { + await _prefs.setBool(_balanceHiddenKey, hidden); + } + + bool isBalanceHidden() { + return _prefs.getBool(_balanceHiddenKey) ?? false; + } + // --- Primitive Accessors for General Use --- /// Get a boolean value from SharedPreferences diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart new file mode 100644 index 00000000..0a9997e6 --- /dev/null +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -0,0 +1,205 @@ +import 'dart:math'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum SwapStatus { pending, depositing, processing, complete, failed, expired } + +class SwapToken { + final String symbol; + final String name; + final String network; + final int decimals; + + const SwapToken({required this.symbol, required this.name, required this.network, this.decimals = 18}); + + @override + bool operator ==(Object other) => other is SwapToken && symbol == other.symbol && network == other.network; + + @override + int get hashCode => Object.hash(symbol, network); +} + +class SwapQuote { + final String quoteId; + final SwapToken fromToken; + final SwapToken toToken; + final double fromAmount; + final double toAmount; + final double rate; + final double networkFee; + final double totalAmount; + final double slippageTolerance; + final Duration expiresIn; + + const SwapQuote({ + required this.quoteId, + required this.fromToken, + required this.toToken, + required this.fromAmount, + required this.toAmount, + required this.rate, + required this.networkFee, + required this.totalAmount, + required this.slippageTolerance, + required this.expiresIn, + }); +} + +class SwapOrder { + final String orderId; + final SwapQuote quote; + final String depositAddress; + final SwapStatus status; + final DateTime createdAt; + + const SwapOrder({ + required this.orderId, + required this.quote, + required this.depositAddress, + required this.status, + required this.createdAt, + }); + + SwapOrder copyWith({SwapStatus? status}) => SwapOrder( + orderId: orderId, + quote: quote, + depositAddress: depositAddress, + status: status ?? this.status, + createdAt: createdAt, + ); +} + +class SwapService { + static final SwapService _instance = SwapService._(); + factory SwapService() => _instance; + SwapService._(); + + static const _refundAddressKey = 'recent_refund_addresses'; + static const _maxRefundAddresses = 50; + final _orders = {}; + + static const availableTokens = [ + SwapToken(symbol: 'USDC', name: 'USD Coin', network: 'Ethereum'), + SwapToken(symbol: 'USDT', name: 'Tether', network: 'Ethereum'), + SwapToken(symbol: 'ETH', name: 'Ethereum', network: 'Ethereum'), + SwapToken(symbol: 'BTC', name: 'Bitcoin', network: 'Bitcoin', decimals: 8), + SwapToken(symbol: 'SOL', name: 'Solana', network: 'Solana', decimals: 9), + SwapToken(symbol: 'QUAN', name: 'Quantus', network: 'Quantus'), + ]; + + static const _quToken = SwapToken(symbol: 'QUAN', name: 'Quantus', network: 'Quantus'); + + List getFromTokens() => availableTokens.where((t) => t.symbol != 'QUAN').toList(); + + SwapToken getQuToken() => _quToken; + + double getRate(SwapToken from) { + switch (from.symbol) { + case 'USDC': + case 'USDT': + return 10.0; + case 'ETH': + return 25000.0; + case 'BTC': + return 60000.0; + case 'SOL': + return 1500.0; + default: + return 1.0; + } + } + + double getUsdPrice(SwapToken token) { + switch (token.symbol) { + case 'USDC': + case 'USDT': + return 1.0; + case 'ETH': + return 2500.0; + case 'BTC': + return 60000.0; + case 'SOL': + return 150.0; + case 'QUAN': + return 0.10; + default: + return 0.0; + } + } + + Future getQuote({required SwapToken fromToken, required double fromAmount, double slippage = 0.01}) async { + await Future.delayed(const Duration(milliseconds: 500)); + final rate = getRate(fromToken); + final toAmount = fromAmount * rate; + final networkFee = fromAmount * 0.005; + final totalAmount = fromAmount + networkFee; + + return SwapQuote( + quoteId: 'quote_${DateTime.now().millisecondsSinceEpoch}', + fromToken: fromToken, + toToken: _quToken, + fromAmount: fromAmount, + toAmount: toAmount, + rate: rate, + networkFee: networkFee, + totalAmount: totalAmount, + slippageTolerance: slippage, + expiresIn: const Duration(minutes: 5), + ); + } + + Future createSwap(SwapQuote quote) async { + await Future.delayed(const Duration(milliseconds: 300)); + final rng = Random(); + final hex = List.generate(40, (_) => rng.nextInt(16).toRadixString(16)).join(); + final order = SwapOrder( + orderId: 'swap_${DateTime.now().millisecondsSinceEpoch}', + quote: quote, + depositAddress: '0x$hex', + status: SwapStatus.depositing, + createdAt: DateTime.now(), + ); + _orders[order.orderId] = order; + return order; + } + + Future getSwapStatus(String orderId) async { + await Future.delayed(const Duration(milliseconds: 200)); + final order = _orders[orderId]; + if (order == null) throw Exception('Order not found'); + return order; + } + + Future confirmFundsSent(String orderId) async { + await Future.delayed(const Duration(milliseconds: 300)); + final order = _orders[orderId]; + if (order == null) throw Exception('Order not found'); + final updated = order.copyWith(status: SwapStatus.processing); + _orders[orderId] = updated; + + Future.delayed(const Duration(seconds: 5), () { + if (_orders.containsKey(orderId)) { + _orders[orderId] = _orders[orderId]!.copyWith(status: SwapStatus.complete); + } + }); + + return updated; + } + + Future addRefundAddress(String network, String address) async { + final prefs = await SharedPreferences.getInstance(); + final key = '${_refundAddressKey}_${network.toLowerCase()}'; + var addresses = prefs.getStringList(key) ?? []; + addresses.remove(address); + addresses.insert(0, address); + if (addresses.length > _maxRefundAddresses) { + addresses = addresses.sublist(0, _maxRefundAddresses); + } + await prefs.setStringList(key, addresses); + } + + Future> getRefundAddresses(String network) async { + final prefs = await SharedPreferences.getInstance(); + final key = '${_refundAddressKey}_${network.toLowerCase()}'; + return prefs.getStringList(key) ?? []; + } +} diff --git a/quantus_sdk/pubspec.lock b/quantus_sdk/pubspec.lock index 334c9cbb..c77ae1d3 100644 --- a/quantus_sdk/pubspec.lock +++ b/quantus_sdk/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "88.0.0" + version: "93.0.0" adaptive_number: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "10.0.1" args: dependency: transitive description: @@ -65,14 +65,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" build_cli_annotations: dependency: transitive description: name: build_cli_annotations - sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172 + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" built_collection: dependency: transitive description: @@ -85,10 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: ba95c961bafcd8686d1cf63be864eb59447e795e124d98d6a27d91fcd13602fb + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.11.1" + version: "8.12.3" characters: dependency: transitive description: @@ -97,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -109,10 +133,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.1" collection: dependency: "direct main" description: @@ -145,46 +169,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" - url: "https://pub.dev" - source: hosted - version: "0.3.4+2" crypto: dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" cryptography: dependency: transitive description: name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.9.0" dart_style: dependency: transitive description: name: dart_style - sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.5" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" decimal: dependency: "direct main" description: @@ -213,10 +229,10 @@ packages: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" fake_async: dependency: transitive description: @@ -229,10 +245,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -295,50 +311,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.4" - flutter_secure_storage_linux: + version: "10.0.0" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: + version: "0.2.0" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -374,10 +390,10 @@ packages: dependency: "direct main" description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" http_parser: dependency: transitive description: @@ -408,30 +424,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - js: + json_annotation: dependency: transitive description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "4.10.0" json_schema: dependency: transitive description: name: json_schema - sha256: fc8c3e280c7647ed8e94f2565ba107ef4aff94b096764b8460a7e1ae39f20382 + sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a + url: "https://pub.dev" + source: hosted + version: "5.2.2" + json_serializable: + dependency: transitive + description: + name: json_serializable + sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "6.12.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -452,10 +476,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -532,18 +556,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -604,34 +628,34 @@ packages: dependency: "direct main" description: name: polkadart - sha256: c91901620ba4b0de1f80fe406d509a404f3ed3fde059e7483a1a597ee4ab96da + sha256: eff6fdbf48b724602e0b7fe73c741995336adb19364977598fbbce4bfe3b2567 url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "1.1.0" polkadart_cli: dependency: "direct main" description: name: polkadart_cli - sha256: "3c2371134031c518b7728e24fd1b5dbcce98a364c97dd5a89518cccfc7222712" + sha256: "8429c3dc08d3b388178d57357172790fc0e00b8a085f0b90ed99e8146cd9677e" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "1.0.1" polkadart_keyring: dependency: "direct main" description: name: polkadart_keyring - sha256: cb1b9733bfbd603d73410ca68e808b0921e24291cdc02ade18907980c5c6872c + sha256: "302001dfaf87c25de398c4270e827bb97712ef5da095b5fb517967f2e8ba0b0f" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" polkadart_scale_codec: dependency: transitive description: name: polkadart_scale_codec - sha256: "07044bf15d5c02ee79984b2696dcf25c598563eca12e70bc9ff45dd7948d93d5" + sha256: "92164062f66ba3b3e99437bc591f3a9a68c0122518f676061b5d67470db68a12" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "2.0.1" process: dependency: transitive description: @@ -648,6 +672,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" quiver: dependency: "direct main" description: @@ -676,10 +708,10 @@ packages: dependency: transitive description: name: rfc_6901 - sha256: df1bbfa3d023009598f19636d6114c6ac1e0b7bb7bf6a260f0e6e6ce91416820 + sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.1" ristretto255: dependency: transitive description: @@ -699,34 +731,34 @@ packages: dependency: transitive description: name: secp256k1_ecdsa - sha256: e2215e3351ad24b603d856a55814ddc90bc158f3ff25efecde4ab318a71a04a7 + sha256: "137c36cee217c322d8e87ad8dea76e0e976230f26573528bae7bfa301c2f3264" url: "https://pub.dev" source: hosted - version: "0.6.2" + version: "0.6.3" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -764,30 +796,46 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + url: "https://pub.dev" + source: hosted + version: "1.3.10" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sr25519: dependency: transitive description: name: sr25519 - sha256: "38c840abe245d4e777f1b7593d8f72ae463801c8ef9012a00d2d244f3a944fe3" + sha256: "689bbb2c8805b33feae65179c778a8af6c3976029bc5a31d526b88c4749d8138" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" ss58: dependency: "direct main" description: name: ss58 - sha256: ad12bcdc909e73648aba52754b1eab81880bd2cbc4fc6cbaa02695affe49201d + sha256: ba978510735192593ae15fc92fec4c400b23a08fcaa6f156876f97835a18c8cf url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "2.0.1" stack_trace: dependency: transitive description: @@ -824,18 +872,18 @@ packages: dependency: transitive description: name: substrate_bip39 - sha256: "8103aafb10df3b489feaadfc8874dbe2d8474f3deab0383657b4fa5af22fcb39" + sha256: "877cdba1536b6c9bfb2f3821f7d0893bf99be0627573b2944eada48ff4049e15" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" substrate_metadata: dependency: transitive description: name: substrate_metadata - sha256: "252a4a00a23b7da4982d7d2ac5154b6164fd71b3eb0a113faa0ce7983b4e0035" + sha256: "60e57a4b1fdfbe55621d190e356187add54d1a3b26254306404fa91334d0f53a" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "2.1.0" sync_http: dependency: transitive description: @@ -872,10 +920,10 @@ packages: dependency: transitive description: name: unorm_dart - sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" url: "https://pub.dev" source: hosted - version: "0.3.1+1" + version: "0.3.2" uri: dependency: transitive description: @@ -884,14 +932,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - utility: - dependency: transitive - description: - name: utility - sha256: "200d264c3804e87da7ea36aa81bd73fb845d2cb7b2e820f3f357a0a2bd4e37f5" - url: "https://pub.dev" - source: hosted - version: "1.0.3" vector_math: dependency: transitive description: @@ -912,10 +952,10 @@ packages: dependency: transitive description: name: watcher - sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.1" web: dependency: transitive description: @@ -960,10 +1000,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -989,5 +1029,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0"