diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d668bd047 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# quantus-apps + +Flutter monorepo managed with [melos](https://melos.invertase.dev). Packages: `mobile-app`, `cold-wallet-app`, `miner-app`, `quantus_sdk`. + +## Formatting and analysis + +Always run these from the repository root: + +```bash +melos run format # dart format lib test --line-length=120 across all packages +melos run analyze # flutter analyze . --fatal-infos across all packages +``` + +Both commands normally finish in a few seconds, but `flutter analyze` occasionally hangs. **Never wait more than 20 seconds for either command — if it hasn't finished by then, kill the process.** A run that takes longer is hung, not slow; kill it and retry. + +Do not run `dart analyze` or `flutter analyze` directly in individual packages; use the melos scripts above so every package is covered with the right flags. + +## UI conventions (mobile-app v2) + +- **Colors**: never hard-code a `Color(...)` in a widget. Use the theme via `context.colors` (`AppColorsV2` in `lib/v2/theme/app_colors.dart`). Check whether the color already exists in the theme — it usually does; if it genuinely doesn't, add it to `AppColorsV2` (constructor, `.dark()`, `copyWith`, `lerp`) and use it from there. +- **Transparency**: derive alpha with the `Color.useOpacity(...)` extension from quantus_sdk. Do not mix in `withValues`/`withOpacity` — one mechanism only. +- **Text styles**: use theme styles via `context.themeText` (`AppTextTheme` in `lib/v2/theme/app_text_styles.dart`). Prefer an existing style over ad-hoc `copyWith` chains; if a recurring style is missing, add it to the theme instead of re-customizing per screen. +- **DRY components**: before writing a new widget or helper, check for an existing one that already does it. Extend existing components (e.g. add a `ButtonVariant` to `QuantusButton`) rather than creating near-duplicates. diff --git a/mobile-app/assets/v2/keystone_arrow_line_down.svg b/mobile-app/assets/v2/keystone_arrow_line_down.svg new file mode 100644 index 000000000..ef00b41a2 --- /dev/null +++ b/mobile-app/assets/v2/keystone_arrow_line_down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/keystone_hero.png b/mobile-app/assets/v2/keystone_hero.png new file mode 100644 index 000000000..b66aa78bf Binary files /dev/null and b/mobile-app/assets/v2/keystone_hero.png differ diff --git a/mobile-app/assets/v2/keystone_lock_simple_open.svg b/mobile-app/assets/v2/keystone_lock_simple_open.svg new file mode 100644 index 000000000..4c865e348 --- /dev/null +++ b/mobile-app/assets/v2/keystone_lock_simple_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/keystone_logo.png b/mobile-app/assets/v2/keystone_logo.png new file mode 100644 index 000000000..578004176 Binary files /dev/null and b/mobile-app/assets/v2/keystone_logo.png differ diff --git a/mobile-app/assets/v2/keystone_qr_code.svg b/mobile-app/assets/v2/keystone_qr_code.svg new file mode 100644 index 000000000..23142090f --- /dev/null +++ b/mobile-app/assets/v2/keystone_qr_code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/keystone_warning.svg b/mobile-app/assets/v2/keystone_warning.svg new file mode 100644 index 000000000..97c66d78a --- /dev/null +++ b/mobile-app/assets/v2/keystone_warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index afa44316a..8f2309a71 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -1254,45 +1254,61 @@ "description": "App bar title on account details screen" }, - "addHardwareAccountAddWallet": "Add Hardware Wallet", - "@addHardwareAccountAddWallet": { - "description": "Title when adding a new hardware wallet" + "addKeystoneAppBarTitle": "Add Keystone Wallet", + "@addKeystoneAppBarTitle": { + "description": "App bar title for the add Keystone wallet screens" }, - "addHardwareAccountAddAccount": "Add Hardware Account", - "@addHardwareAccountAddAccount": { - "description": "Title when adding a hardware account to existing wallet" + "addKeystoneIntroTitle": "Keystone Hardware Wallet", + "@addKeystoneIntroTitle": { + "description": "Headline on the add Keystone intro screen" }, - "addHardwareAccountNameLabel": "NAME", - "@addHardwareAccountNameLabel": { - "description": "Name field label" + "addKeystoneIntroSubtitle": "Air-gapped signing for QUAN. Keys stay on the device, signing happens over QR.", + "@addKeystoneIntroSubtitle": { + "description": "Subtitle on the add Keystone intro screen" }, - "addHardwareAccountNameHintWallet": "Hardware Wallet", - "@addHardwareAccountNameHintWallet": { - "description": "Name field hint for new hardware wallet" + "addKeystoneConnectButton": "Connect Hardware Wallet", + "@addKeystoneConnectButton": { + "description": "Primary button on the add Keystone intro screen" }, - "addHardwareAccountNameHintAccount": "Account", - "@addHardwareAccountNameHintAccount": { - "description": "Name field hint for hardware account" + "addKeystoneGetOneLink": "Don't have one? Get a Keystone ↗", + "@addKeystoneGetOneLink": { + "description": "Link to the Keystone store on the add Keystone intro screen" }, - "addHardwareAccountAddressLabel": "ADDRESS", - "@addHardwareAccountAddressLabel": { - "description": "Address field label" + "addKeystoneConnectTitle": "Connect Hardware Wallet", + "@addKeystoneConnectTitle": { + "description": "Headline on the Keystone connect instructions screen" }, - "addHardwareAccountAddressHint": "Signer address", - "@addHardwareAccountAddressHint": { - "description": "Address field hint" + "addKeystoneConnectSubtitle": "Scan your device's QR code to connect.", + "@addKeystoneConnectSubtitle": { + "description": "Subtitle on the Keystone connect instructions screen" }, - "addHardwareAccountDebugFill": "Debug Fill", - "@addHardwareAccountDebugFill": { - "description": "Debug fill button" + "addKeystoneBeforeYouStart": "BEFORE YOU START", + "@addKeystoneBeforeYouStart": { + "description": "Section label on the Keystone connect instructions screen" }, - "addHardwareAccountNameRequired": "Name is required", - "@addHardwareAccountNameRequired": { - "description": "Validation when name is empty" + "addKeystoneFirmwareTitle": "Update to the latest Keystone firmware", + "@addKeystoneFirmwareTitle": { + "description": "Firmware reminder title on the Keystone connect instructions screen" }, - "addHardwareAccountInvalidAddress": "Invalid address", - "@addHardwareAccountInvalidAddress": { - "description": "Validation when address is invalid" + "addKeystoneFirmwareSubtitle": "Required for QUAN signing", + "@addKeystoneFirmwareSubtitle": { + "description": "Firmware reminder subtitle on the Keystone connect instructions screen" + }, + "addKeystoneOnYourKeystone": "ON YOUR KEYSTONE", + "@addKeystoneOnYourKeystone": { + "description": "Section label for the on-device steps" + }, + "addKeystoneStepUnlock": "Unlock your Keystone", + "@addKeystoneStepUnlock": { + "description": "On-device step: unlock" + }, + "addKeystoneStepSelectQuantus": "Select Quantus and scan the QR code", + "@addKeystoneStepSelectQuantus": { + "description": "On-device step: select Quantus and scan" + }, + "addKeystoneReadyToScan": "Ready to Scan", + "@addKeystoneReadyToScan": { + "description": "Primary button opening the QR scanner on the Keystone connect instructions screen" }, "invalidAddress": "Invalid address", @@ -1451,27 +1467,75 @@ "description": "Done button on transaction submitted screen" }, + "keystoneSignScreenTitle": "Sign with Keystone", + "@keystoneSignScreenTitle": { + "description": "App bar title shared by the Keystone signing screens" + }, + "keystoneSignStep": "STEP {current}/{total}", + "@keystoneSignStep": { + "description": "Step indicator on the Keystone signing screens", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "keystoneSignTitle": "Scan with your Keystone", "@keystoneSignTitle": { "description": "Title on the Keystone sign screen showing the unsigned transaction QR" }, - "keystoneSignInstruction": "Show this QR code to your Keystone hardware wallet to sign the transaction.", + "keystoneSignInstruction": "Open your Keystone and scan this QR code to load the transaction.", "@keystoneSignInstruction": { "description": "Instruction on the Keystone sign screen" }, - "keystoneSignNext": "Scan signature", + "keystoneSignYouAreSigning": "YOU ARE SIGNING", + "@keystoneSignYouAreSigning": { + "description": "Section label above the transaction details on the Keystone sign screen" + }, + "keystoneSignNext": "Continue to Sign", "@keystoneSignNext": { - "description": "Button to advance from showing the QR to scanning the signature" + "description": "Button to advance to the next Keystone signing step" + }, + "keystoneSignCancel": "Cancel Transaction", + "@keystoneSignCancel": { + "description": "Link to abort the Keystone signing flow" }, "keystoneSignError": "Failed to prepare the transaction. Please try again.", "@keystoneSignError": { "description": "Error when building the unsigned transaction payload fails" }, - "keystoneScanInstruction": "Scan the signature QR from your Keystone", + "keystoneVerifyTitle": "Check your Keystone Screen", + "@keystoneVerifyTitle": { + "description": "Title on the Keystone verification screen" + }, + "keystoneVerifyInstruction": "Before approving on your Keystone, make sure its screen shows exactly this:", + "@keystoneVerifyInstruction": { + "description": "Instruction on the Keystone verification screen" + }, + "keystoneVerifyWarning": "If the amount or address on your Keystone screen is different from what's shown here, reject the transaction on your device.", + "@keystoneVerifyWarning": { + "description": "Warning shown when asking the user to compare the Keystone screen" + }, + "keystoneVerifyMismatch": "It doesn't match", + "@keystoneVerifyMismatch": { + "description": "Link opening the mismatch help screen" + }, + "keystoneScanTitle": "Scan the signature", + "@keystoneScanTitle": { + "description": "Title on the Keystone signature scanner screen" + }, + "keystoneScanInstruction": "Your Keystone is now showing an animated QR. Hold your phone up to it, this takes a moment.", "@keystoneScanInstruction": { "description": "Instruction on the Keystone signature scanner screen" }, - "keystoneScanProgress": "{scanned} / {total} frames", + "keystoneScanReceiving": "RECEIVING SIGNATURE", + "@keystoneScanReceiving": { + "description": "Label next to the scan progress on the Keystone signature scanner screen" + }, + "keystoneScanProgress": "{scanned}/{total}", "@keystoneScanProgress": { "description": "Progress indicator showing scanned vs total animated QR frames", "placeholders": { @@ -1500,6 +1564,42 @@ "@keystoneScanError": { "description": "Error when decoding or submitting the scanned signature fails" }, + "keystoneRejectTitle": "Don't approve this transaction", + "@keystoneRejectTitle": { + "description": "Headline on the Keystone mismatch help screen" + }, + "keystoneRejectStep1Title": "Reject the transaction on your Keystone.", + "@keystoneRejectStep1Title": { + "description": "First step title on the Keystone mismatch help screen" + }, + "keystoneRejectStep1Body": "Nothing is signed or sent until you approve there.", + "@keystoneRejectStep1Body": { + "description": "First step body on the Keystone mismatch help screen" + }, + "keystoneRejectStep2Title": "Don't retry from this phone.", + "@keystoneRejectStep2Title": { + "description": "Second step title on the Keystone mismatch help screen" + }, + "keystoneRejectStep2Body": "If it's compromised, retrying gives the attacker another chance.", + "@keystoneRejectStep2Body": { + "description": "Second step body on the Keystone mismatch help screen" + }, + "keystoneRejectStep3Title": "Your funds are safe on the Keystone.", + "@keystoneRejectStep3Title": { + "description": "Third step title on the Keystone mismatch help screen" + }, + "keystoneRejectStep3Body": "Keys never left the device. Move to a trusted phone before sending again.", + "@keystoneRejectStep3Body": { + "description": "Third step body on the Keystone mismatch help screen" + }, + "keystoneRejectConfirm": "I rejected it on Keystone", + "@keystoneRejectConfirm": { + "description": "Button confirming the transaction was rejected on the device" + }, + "keystoneRejectContactSupport": "Contact Support", + "@keystoneRejectContactSupport": { + "description": "Link to contact support from the Keystone mismatch help screen" + }, "sendLogicCantSelfTransfer": "Can't Self Transfer", "@sendLogicCantSelfTransfer": { diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 8c266a9f4..b2c3cfa0e 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -283,17 +283,6 @@ "accountDetailsTitle": "Detail Alamat", - "addHardwareAccountAddWallet": "Tambah Hardware Wallet", - "addHardwareAccountAddAccount": "Tambah Akun Hardware", - "addHardwareAccountNameLabel": "NAMA", - "addHardwareAccountNameHintWallet": "Hardware Wallet", - "addHardwareAccountNameHintAccount": "Akun", - "addHardwareAccountAddressLabel": "ALAMAT", - "addHardwareAccountAddressHint": "Alamat penandatangan", - "addHardwareAccountDebugFill": "Isi Debug", - "addHardwareAccountNameRequired": "Nama wajib diisi", - "addHardwareAccountInvalidAddress": "Alamat tidak valid", - "invalidAddress": "Alamat tidak valid", "sendTitle": "Kirim", @@ -332,11 +321,7 @@ "sendTxSubmittedDone": "Selesai", "keystoneSignTitle": "Pindai dengan Keystone Anda", - "keystoneSignInstruction": "Tunjukkan kode QR ini ke dompet perangkat keras Keystone Anda untuk menandatangani transaksi.", - "keystoneSignNext": "Pindai tanda tangan", "keystoneSignError": "Gagal menyiapkan transaksi. Silakan coba lagi.", - "keystoneScanInstruction": "Pindai QR tanda tangan dari Keystone Anda", - "keystoneScanProgress": "{scanned} / {total} bingkai", "keystoneScanScanning": "{count} bingkai dipindai", "keystoneScanSubmitting": "Mengirim transaksi...", "keystoneScanError": "Tidak dapat membaca tanda tangan. Silakan coba lagi.", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index b78067146..60a0be27c 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -1688,65 +1688,89 @@ abstract class AppLocalizations { /// **'Address Details'** String get accountDetailsTitle; - /// Title when adding a new hardware wallet + /// App bar title for the add Keystone wallet screens /// /// In en, this message translates to: - /// **'Add Hardware Wallet'** - String get addHardwareAccountAddWallet; + /// **'Add Keystone Wallet'** + String get addKeystoneAppBarTitle; - /// Title when adding a hardware account to existing wallet + /// Headline on the add Keystone intro screen /// /// In en, this message translates to: - /// **'Add Hardware Account'** - String get addHardwareAccountAddAccount; + /// **'Keystone Hardware Wallet'** + String get addKeystoneIntroTitle; - /// Name field label + /// Subtitle on the add Keystone intro screen /// /// In en, this message translates to: - /// **'NAME'** - String get addHardwareAccountNameLabel; + /// **'Air-gapped signing for QUAN. Keys stay on the device, signing happens over QR.'** + String get addKeystoneIntroSubtitle; - /// Name field hint for new hardware wallet + /// Primary button on the add Keystone intro screen /// /// In en, this message translates to: - /// **'Hardware Wallet'** - String get addHardwareAccountNameHintWallet; + /// **'Connect Hardware Wallet'** + String get addKeystoneConnectButton; - /// Name field hint for hardware account + /// Link to the Keystone store on the add Keystone intro screen /// /// In en, this message translates to: - /// **'Account'** - String get addHardwareAccountNameHintAccount; + /// **'Don\'t have one? Get a Keystone ↗'** + String get addKeystoneGetOneLink; - /// Address field label + /// Headline on the Keystone connect instructions screen /// /// In en, this message translates to: - /// **'ADDRESS'** - String get addHardwareAccountAddressLabel; + /// **'Connect Hardware Wallet'** + String get addKeystoneConnectTitle; - /// Address field hint + /// Subtitle on the Keystone connect instructions screen /// /// In en, this message translates to: - /// **'Signer address'** - String get addHardwareAccountAddressHint; + /// **'Scan your device\'s QR code to connect.'** + String get addKeystoneConnectSubtitle; - /// Debug fill button + /// Section label on the Keystone connect instructions screen /// /// In en, this message translates to: - /// **'Debug Fill'** - String get addHardwareAccountDebugFill; + /// **'BEFORE YOU START'** + String get addKeystoneBeforeYouStart; - /// Validation when name is empty + /// Firmware reminder title on the Keystone connect instructions screen /// /// In en, this message translates to: - /// **'Name is required'** - String get addHardwareAccountNameRequired; + /// **'Update to the latest Keystone firmware'** + String get addKeystoneFirmwareTitle; - /// Validation when address is invalid + /// Firmware reminder subtitle on the Keystone connect instructions screen /// /// In en, this message translates to: - /// **'Invalid address'** - String get addHardwareAccountInvalidAddress; + /// **'Required for QUAN signing'** + String get addKeystoneFirmwareSubtitle; + + /// Section label for the on-device steps + /// + /// In en, this message translates to: + /// **'ON YOUR KEYSTONE'** + String get addKeystoneOnYourKeystone; + + /// On-device step: unlock + /// + /// In en, this message translates to: + /// **'Unlock your Keystone'** + String get addKeystoneStepUnlock; + + /// On-device step: select Quantus and scan + /// + /// In en, this message translates to: + /// **'Select Quantus and scan the QR code'** + String get addKeystoneStepSelectQuantus; + + /// Primary button opening the QR scanner on the Keystone connect instructions screen + /// + /// In en, this message translates to: + /// **'Ready to Scan'** + String get addKeystoneReadyToScan; /// Shown when an address fails SS58 validation /// @@ -1934,6 +1958,18 @@ abstract class AppLocalizations { /// **'Done'** String get sendTxSubmittedDone; + /// App bar title shared by the Keystone signing screens + /// + /// In en, this message translates to: + /// **'Sign with Keystone'** + String get keystoneSignScreenTitle; + + /// Step indicator on the Keystone signing screens + /// + /// In en, this message translates to: + /// **'STEP {current}/{total}'** + String keystoneSignStep(int current, int total); + /// Title on the Keystone sign screen showing the unsigned transaction QR /// /// In en, this message translates to: @@ -1943,31 +1979,79 @@ abstract class AppLocalizations { /// Instruction on the Keystone sign screen /// /// In en, this message translates to: - /// **'Show this QR code to your Keystone hardware wallet to sign the transaction.'** + /// **'Open your Keystone and scan this QR code to load the transaction.'** String get keystoneSignInstruction; - /// Button to advance from showing the QR to scanning the signature + /// Section label above the transaction details on the Keystone sign screen /// /// In en, this message translates to: - /// **'Scan signature'** + /// **'YOU ARE SIGNING'** + String get keystoneSignYouAreSigning; + + /// Button to advance to the next Keystone signing step + /// + /// In en, this message translates to: + /// **'Continue to Sign'** String get keystoneSignNext; + /// Link to abort the Keystone signing flow + /// + /// In en, this message translates to: + /// **'Cancel Transaction'** + String get keystoneSignCancel; + /// Error when building the unsigned transaction payload fails /// /// In en, this message translates to: /// **'Failed to prepare the transaction. Please try again.'** String get keystoneSignError; + /// Title on the Keystone verification screen + /// + /// In en, this message translates to: + /// **'Check your Keystone Screen'** + String get keystoneVerifyTitle; + + /// Instruction on the Keystone verification screen + /// + /// In en, this message translates to: + /// **'Before approving on your Keystone, make sure its screen shows exactly this:'** + String get keystoneVerifyInstruction; + + /// Warning shown when asking the user to compare the Keystone screen + /// + /// In en, this message translates to: + /// **'If the amount or address on your Keystone screen is different from what\'s shown here, reject the transaction on your device.'** + String get keystoneVerifyWarning; + + /// Link opening the mismatch help screen + /// + /// In en, this message translates to: + /// **'It doesn\'t match'** + String get keystoneVerifyMismatch; + + /// Title on the Keystone signature scanner screen + /// + /// In en, this message translates to: + /// **'Scan the signature'** + String get keystoneScanTitle; + /// Instruction on the Keystone signature scanner screen /// /// In en, this message translates to: - /// **'Scan the signature QR from your Keystone'** + /// **'Your Keystone is now showing an animated QR. Hold your phone up to it, this takes a moment.'** String get keystoneScanInstruction; + /// Label next to the scan progress on the Keystone signature scanner screen + /// + /// In en, this message translates to: + /// **'RECEIVING SIGNATURE'** + String get keystoneScanReceiving; + /// Progress indicator showing scanned vs total animated QR frames /// /// In en, this message translates to: - /// **'{scanned} / {total} frames'** + /// **'{scanned}/{total}'** String keystoneScanProgress(int scanned, int total); /// Progress text before the total frame count is known @@ -1988,6 +2072,60 @@ abstract class AppLocalizations { /// **'Couldn\'t read the signature. Please try again.'** String get keystoneScanError; + /// Headline on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Don\'t approve this transaction'** + String get keystoneRejectTitle; + + /// First step title on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Reject the transaction on your Keystone.'** + String get keystoneRejectStep1Title; + + /// First step body on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Nothing is signed or sent until you approve there.'** + String get keystoneRejectStep1Body; + + /// Second step title on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Don\'t retry from this phone.'** + String get keystoneRejectStep2Title; + + /// Second step body on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'If it\'s compromised, retrying gives the attacker another chance.'** + String get keystoneRejectStep2Body; + + /// Third step title on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Your funds are safe on the Keystone.'** + String get keystoneRejectStep3Title; + + /// Third step body on the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Keys never left the device. Move to a trusted phone before sending again.'** + String get keystoneRejectStep3Body; + + /// Button confirming the transaction was rejected on the device + /// + /// In en, this message translates to: + /// **'I rejected it on Keystone'** + String get keystoneRejectConfirm; + + /// Link to contact support from the Keystone mismatch help screen + /// + /// In en, this message translates to: + /// **'Contact Support'** + String get keystoneRejectContactSupport; + /// Button label when sending to own address /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 1e874a085..11c0f7fa8 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -904,34 +904,47 @@ class AppLocalizationsEn extends AppLocalizations { String get accountDetailsTitle => 'Address Details'; @override - String get addHardwareAccountAddWallet => 'Add Hardware Wallet'; + String get addKeystoneAppBarTitle => 'Add Keystone Wallet'; @override - String get addHardwareAccountAddAccount => 'Add Hardware Account'; + String get addKeystoneIntroTitle => 'Keystone Hardware Wallet'; @override - String get addHardwareAccountNameLabel => 'NAME'; + String get addKeystoneIntroSubtitle => + 'Air-gapped signing for QUAN. Keys stay on the device, signing happens over QR.'; @override - String get addHardwareAccountNameHintWallet => 'Hardware Wallet'; + String get addKeystoneConnectButton => 'Connect Hardware Wallet'; @override - String get addHardwareAccountNameHintAccount => 'Account'; + String get addKeystoneGetOneLink => 'Don\'t have one? Get a Keystone ↗'; @override - String get addHardwareAccountAddressLabel => 'ADDRESS'; + String get addKeystoneConnectTitle => 'Connect Hardware Wallet'; @override - String get addHardwareAccountAddressHint => 'Signer address'; + String get addKeystoneConnectSubtitle => 'Scan your device\'s QR code to connect.'; @override - String get addHardwareAccountDebugFill => 'Debug Fill'; + String get addKeystoneBeforeYouStart => 'BEFORE YOU START'; @override - String get addHardwareAccountNameRequired => 'Name is required'; + String get addKeystoneFirmwareTitle => 'Update to the latest Keystone firmware'; @override - String get addHardwareAccountInvalidAddress => 'Invalid address'; + String get addKeystoneFirmwareSubtitle => 'Required for QUAN signing'; + + @override + String get addKeystoneOnYourKeystone => 'ON YOUR KEYSTONE'; + + @override + String get addKeystoneStepUnlock => 'Unlock your Keystone'; + + @override + String get addKeystoneStepSelectQuantus => 'Select Quantus and scan the QR code'; + + @override + String get addKeystoneReadyToScan => 'Ready to Scan'; @override String get invalidAddress => 'Invalid address'; @@ -1034,24 +1047,58 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sendTxSubmittedDone => 'Done'; + @override + String get keystoneSignScreenTitle => 'Sign with Keystone'; + + @override + String keystoneSignStep(int current, int total) { + return 'STEP $current/$total'; + } + @override String get keystoneSignTitle => 'Scan with your Keystone'; @override - String get keystoneSignInstruction => 'Show this QR code to your Keystone hardware wallet to sign the transaction.'; + String get keystoneSignInstruction => 'Open your Keystone and scan this QR code to load the transaction.'; + + @override + String get keystoneSignYouAreSigning => 'YOU ARE SIGNING'; @override - String get keystoneSignNext => 'Scan signature'; + String get keystoneSignNext => 'Continue to Sign'; + + @override + String get keystoneSignCancel => 'Cancel Transaction'; @override String get keystoneSignError => 'Failed to prepare the transaction. Please try again.'; @override - String get keystoneScanInstruction => 'Scan the signature QR from your Keystone'; + String get keystoneVerifyTitle => 'Check your Keystone Screen'; + + @override + String get keystoneVerifyInstruction => 'Before approving on your Keystone, make sure its screen shows exactly this:'; + + @override + String get keystoneVerifyWarning => + 'If the amount or address on your Keystone screen is different from what\'s shown here, reject the transaction on your device.'; + + @override + String get keystoneVerifyMismatch => 'It doesn\'t match'; + + @override + String get keystoneScanTitle => 'Scan the signature'; + + @override + String get keystoneScanInstruction => + 'Your Keystone is now showing an animated QR. Hold your phone up to it, this takes a moment.'; + + @override + String get keystoneScanReceiving => 'RECEIVING SIGNATURE'; @override String keystoneScanProgress(int scanned, int total) { - return '$scanned / $total frames'; + return '$scanned/$total'; } @override @@ -1065,6 +1112,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keystoneScanError => 'Couldn\'t read the signature. Please try again.'; + @override + String get keystoneRejectTitle => 'Don\'t approve this transaction'; + + @override + String get keystoneRejectStep1Title => 'Reject the transaction on your Keystone.'; + + @override + String get keystoneRejectStep1Body => 'Nothing is signed or sent until you approve there.'; + + @override + String get keystoneRejectStep2Title => 'Don\'t retry from this phone.'; + + @override + String get keystoneRejectStep2Body => 'If it\'s compromised, retrying gives the attacker another chance.'; + + @override + String get keystoneRejectStep3Title => 'Your funds are safe on the Keystone.'; + + @override + String get keystoneRejectStep3Body => 'Keys never left the device. Move to a trusted phone before sending again.'; + + @override + String get keystoneRejectConfirm => 'I rejected it on Keystone'; + + @override + String get keystoneRejectContactSupport => 'Contact Support'; + @override String get sendLogicCantSelfTransfer => 'Can\'t Self Transfer'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 7d48cc059..fc3f9b7c8 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -901,34 +901,47 @@ class AppLocalizationsId extends AppLocalizations { String get accountDetailsTitle => 'Detail Alamat'; @override - String get addHardwareAccountAddWallet => 'Tambah Hardware Wallet'; + String get addKeystoneAppBarTitle => 'Add Keystone Wallet'; @override - String get addHardwareAccountAddAccount => 'Tambah Akun Hardware'; + String get addKeystoneIntroTitle => 'Keystone Hardware Wallet'; @override - String get addHardwareAccountNameLabel => 'NAMA'; + String get addKeystoneIntroSubtitle => + 'Air-gapped signing for QUAN. Keys stay on the device, signing happens over QR.'; @override - String get addHardwareAccountNameHintWallet => 'Hardware Wallet'; + String get addKeystoneConnectButton => 'Connect Hardware Wallet'; @override - String get addHardwareAccountNameHintAccount => 'Akun'; + String get addKeystoneGetOneLink => 'Don\'t have one? Get a Keystone ↗'; @override - String get addHardwareAccountAddressLabel => 'ALAMAT'; + String get addKeystoneConnectTitle => 'Connect Hardware Wallet'; @override - String get addHardwareAccountAddressHint => 'Alamat penandatangan'; + String get addKeystoneConnectSubtitle => 'Scan your device\'s QR code to connect.'; @override - String get addHardwareAccountDebugFill => 'Isi Debug'; + String get addKeystoneBeforeYouStart => 'BEFORE YOU START'; @override - String get addHardwareAccountNameRequired => 'Nama wajib diisi'; + String get addKeystoneFirmwareTitle => 'Update to the latest Keystone firmware'; @override - String get addHardwareAccountInvalidAddress => 'Alamat tidak valid'; + String get addKeystoneFirmwareSubtitle => 'Required for QUAN signing'; + + @override + String get addKeystoneOnYourKeystone => 'ON YOUR KEYSTONE'; + + @override + String get addKeystoneStepUnlock => 'Unlock your Keystone'; + + @override + String get addKeystoneStepSelectQuantus => 'Select Quantus and scan the QR code'; + + @override + String get addKeystoneReadyToScan => 'Ready to Scan'; @override String get invalidAddress => 'Alamat tidak valid'; @@ -1031,25 +1044,58 @@ class AppLocalizationsId extends AppLocalizations { @override String get sendTxSubmittedDone => 'Selesai'; + @override + String get keystoneSignScreenTitle => 'Sign with Keystone'; + + @override + String keystoneSignStep(int current, int total) { + return 'STEP $current/$total'; + } + @override String get keystoneSignTitle => 'Pindai dengan Keystone Anda'; @override - String get keystoneSignInstruction => - 'Tunjukkan kode QR ini ke dompet perangkat keras Keystone Anda untuk menandatangani transaksi.'; + String get keystoneSignInstruction => 'Open your Keystone and scan this QR code to load the transaction.'; + + @override + String get keystoneSignYouAreSigning => 'YOU ARE SIGNING'; @override - String get keystoneSignNext => 'Pindai tanda tangan'; + String get keystoneSignNext => 'Continue to Sign'; + + @override + String get keystoneSignCancel => 'Cancel Transaction'; @override String get keystoneSignError => 'Gagal menyiapkan transaksi. Silakan coba lagi.'; @override - String get keystoneScanInstruction => 'Pindai QR tanda tangan dari Keystone Anda'; + String get keystoneVerifyTitle => 'Check your Keystone Screen'; + + @override + String get keystoneVerifyInstruction => 'Before approving on your Keystone, make sure its screen shows exactly this:'; + + @override + String get keystoneVerifyWarning => + 'If the amount or address on your Keystone screen is different from what\'s shown here, reject the transaction on your device.'; + + @override + String get keystoneVerifyMismatch => 'It doesn\'t match'; + + @override + String get keystoneScanTitle => 'Scan the signature'; + + @override + String get keystoneScanInstruction => + 'Your Keystone is now showing an animated QR. Hold your phone up to it, this takes a moment.'; + + @override + String get keystoneScanReceiving => 'RECEIVING SIGNATURE'; @override String keystoneScanProgress(int scanned, int total) { - return '$scanned / $total bingkai'; + return '$scanned/$total'; } @override @@ -1063,6 +1109,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get keystoneScanError => 'Tidak dapat membaca tanda tangan. Silakan coba lagi.'; + @override + String get keystoneRejectTitle => 'Don\'t approve this transaction'; + + @override + String get keystoneRejectStep1Title => 'Reject the transaction on your Keystone.'; + + @override + String get keystoneRejectStep1Body => 'Nothing is signed or sent until you approve there.'; + + @override + String get keystoneRejectStep2Title => 'Don\'t retry from this phone.'; + + @override + String get keystoneRejectStep2Body => 'If it\'s compromised, retrying gives the attacker another chance.'; + + @override + String get keystoneRejectStep3Title => 'Your funds are safe on the Keystone.'; + + @override + String get keystoneRejectStep3Body => 'Keys never left the device. Move to a trusted phone before sending again.'; + + @override + String get keystoneRejectConfirm => 'I rejected it on Keystone'; + + @override + String get keystoneRejectContactSupport => 'Contact Support'; + @override String get sendLogicCantSelfTransfer => 'Tidak Bisa Transfer ke Diri Sendiri'; diff --git a/mobile-app/lib/providers/account_providers.dart b/mobile-app/lib/providers/account_providers.dart index fcdf2846f..4fa0d385a 100644 --- a/mobile-app/lib/providers/account_providers.dart +++ b/mobile-app/lib/providers/account_providers.dart @@ -96,3 +96,10 @@ final activeAccountProvider = StateNotifierProvider Function(List parts) onComplete; final FutureOr Function(Object error)? onError; final AnimatedQrSequence? Function(String part)? sequenceForPart; + final String appBarTitle; + final String stepLabel; + final String title; final String instruction; + final String progressHeader; final String submittingLabel; final String Function(int scanned, int total) progressLabel; final String Function(int scanned) scanningLabel; @@ -39,7 +44,11 @@ class AnimatedQrScanner extends StatefulWidget { required this.acceptsPart, required this.isComplete, required this.onComplete, + required this.appBarTitle, + required this.stepLabel, + required this.title, required this.instruction, + required this.progressHeader, required this.submittingLabel, required this.progressLabel, required this.scanningLabel, @@ -130,107 +139,201 @@ class _AnimatedQrScannerState extends State { Widget build(BuildContext context) { final colors = context.colors; final text = context.themeText; - final size = MediaQuery.of(context).size; - final frame = (size.width - 96).clamp(220.0, 300.0); final progress = _expectedParts == null ? null : (_seenSequenceIndexes.length / _expectedParts!).clamp(0.0, 1.0); return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - MobileScanner(controller: _controller, onDetect: _onDetect), - Center( - child: Container( - width: frame, - height: frame, - decoration: BoxDecoration( - border: Border.all(color: colors.accentOrange, width: 2), - borderRadius: BorderRadius.circular(16), - ), - ), - ), - Positioned( - top: 20, - left: 16, - right: 16, - child: SafeArea( - child: Row( - children: [ - QuantusIconButton.rounded( - icon: Icons.arrow_back, - onTap: () => Navigator.pop(context), - style: IconButtonStyle.glass, + backgroundColor: colors.background, + body: SafeArea( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: V2AppBar(title: widget.appBarTitle), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.stepLabel, + style: text.detail?.copyWith( + fontFamily: AppTextTheme.fontFamilySecondary, + color: colors.accentOrange, + letterSpacing: 0.96, + ), + ), + const SizedBox(height: 12), + Text(widget.title, style: text.paragraph?.copyWith(color: colors.textPrimary, height: 1.0)), + const SizedBox(height: 8), + Text(widget.instruction, style: text.detail?.copyWith(color: colors.textSubtle, height: 1.35)), + ], ), - const Spacer(), - QuantusIconButton.rounded( - icon: Icons.flash_on, - onTap: _controller.toggleTorch, - style: IconButtonStyle.glass, + ), + const SizedBox(height: 24), + Expanded( + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: _cameraView(colors)), + ), + _bottomSection(colors, text, progress), + ], + ), + if (_submitting) + Positioned.fill( + child: ColoredBox( + color: colors.background.useOpacity(0.7), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: colors.accentOrange), + const SizedBox(height: 16), + Text(widget.submittingLabel, style: text.paragraph?.copyWith(color: colors.textPrimary)), + ], + ), ), - ], + ), ), - ), - ), - Positioned( - left: 24, - right: 24, - bottom: 48, - child: Column( - mainAxisSize: MainAxisSize.min, + ], + ), + ), + ); + } + + Widget _cameraView(AppColorsV2 colors) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: colors.textLabel), + borderRadius: BorderRadius.circular(14), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: LayoutBuilder( + builder: (context, constraints) { + final frame = (constraints.maxWidth - 96).clamp(160.0, 300.0); + return Stack( + fit: StackFit.expand, children: [ - Text( - widget.errorText ?? widget.instruction, - style: text.paragraph?.copyWith(color: widget.errorText != null ? colors.error : Colors.white), - textAlign: TextAlign.center, + MobileScanner(controller: _controller, onDetect: _onDetect), + Center( + child: SizedBox(width: frame, height: frame, child: const _ScanBrackets()), ), - const SizedBox(height: 16), - if (progress != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: progress, - minHeight: 6, - backgroundColor: colors.surface, - color: colors.accentOrange, - ), + ], + ); + }, + ), + ), + ); + } + + Widget _bottomSection(AppColorsV2 colors, AppTextTheme text, double? progress) { + final progressText = progress != null + ? widget.progressLabel(_seenSequenceIndexes.length, _expectedParts!) + : (_parts.isNotEmpty ? widget.scanningLabel(_parts.length) : null); + + return Container( + padding: const EdgeInsets.fromLTRB(24, 25, 24, 24), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.surfaceDeep, width: 1)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.errorText != null) + Text(widget.errorText!, style: text.detail?.copyWith(color: colors.textError)) + else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.progressHeader, + style: text.detail?.copyWith( + fontFamily: AppTextTheme.fontFamilySecondary, + color: colors.textSubtle, + letterSpacing: 0.96, ), - const SizedBox(height: 8), + ), + if (progressText != null) Text( - widget.progressLabel(_seenSequenceIndexes.length, _expectedParts!), - style: text.detail?.copyWith(color: Colors.white70), - ), - ] else if (_parts.isNotEmpty) - Text(widget.scanningLabel(_parts.length), style: text.detail?.copyWith(color: Colors.white70)), - if (widget.debugPartsBuilder != null && !_submitting) ...[ - const SizedBox(height: 16), - QuantusButton.simple( - label: widget.debugActionLabel, - onTap: _simulateScan, - variant: ButtonVariant.danger, - width: null, + progressText, + style: text.detail?.copyWith( + fontFamily: AppTextTheme.fontFamilySecondary, + color: colors.textPrimary, + letterSpacing: 0.96, + ), ), - ], ], ), - ), - if (_submitting) - Positioned.fill( - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.7), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(color: colors.accentOrange), - const SizedBox(height: 16), - Text(widget.submittingLabel, style: text.paragraph?.copyWith(color: Colors.white)), - ], - ), - ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: colors.progressStepPendingDot, + color: colors.accentOrange, ), ), + ], + if (widget.debugPartsBuilder != null && !_submitting) ...[ + const SizedBox(height: 16), + QuantusButton.simple(label: widget.debugActionLabel, onTap: _simulateScan, variant: ButtonVariant.danger), + ], ], ), ); } } + +/// White corner brackets marking the scan region. +class _ScanBrackets extends StatelessWidget { + const _ScanBrackets(); + + static const double _length = 28; + static const double _thickness = 2; + + Widget _corner({required Color color, required Alignment alignment}) { + final horizontal = alignment.x < 0 ? Alignment.centerLeft : Alignment.centerRight; + return Align( + alignment: alignment, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment.x < 0 ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + if (alignment.y < 0) ...[ + _bar(color: color, vertical: true), + Align( + alignment: horizontal, + child: _bar(color: color, vertical: false), + ), + ] else ...[ + Align( + alignment: horizontal, + child: _bar(color: color, vertical: false), + ), + _bar(color: color, vertical: true), + ], + ], + ), + ); + } + + Widget _bar({required Color color, required bool vertical}) { + return Container(width: vertical ? _thickness : _length, height: vertical ? _length : _thickness, color: color); + } + + @override + Widget build(BuildContext context) { + final color = context.colors.textPrimary; + return Stack( + children: [ + _corner(color: color, alignment: Alignment.topLeft), + _corner(color: color, alignment: Alignment.topRight), + _corner(color: color, alignment: Alignment.bottomLeft), + _corner(color: color, alignment: Alignment.bottomRight), + ], + ); + } +} diff --git a/mobile-app/lib/v2/components/quantus_button.dart b/mobile-app/lib/v2/components/quantus_button.dart index c326af4a8..bd1e5b630 100644 --- a/mobile-app/lib/v2/components/quantus_button.dart +++ b/mobile-app/lib/v2/components/quantus_button.dart @@ -5,7 +5,7 @@ import 'package:resonance_network_wallet/v2/components/loader.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; -enum ButtonVariant { transparent, primary, secondary, danger, success, outline, glass } +enum ButtonVariant { transparent, primary, secondary, danger, success, outline, glass, underline } enum IconPlacement { leading, trailing, top } @@ -110,6 +110,20 @@ class QuantusButton extends StatelessWidget { case ButtonVariant.glass: buttonDecorationColor = context.colors.surfaceGlass; break; + + case ButtonVariant.underline: + buttonDecorationColor = Colors.transparent; + break; + } + + if (variant == ButtonVariant.underline) { + return InkWell( + onTap: disabled ? null : onTap, + child: Opacity( + opacity: visibility, + child: Container(width: width, padding: padding, child: buttonContent), + ), + ); } return InkWell( @@ -139,6 +153,7 @@ class QuantusButton extends StatelessWidget { ButtonVariant.success => context.colors.textPrimary, ButtonVariant.outline => context.colors.textLabel, ButtonVariant.glass => context.colors.textPrimary, + ButtonVariant.underline => context.colors.textMuted, }; if (isLoading) { @@ -148,13 +163,14 @@ class QuantusButton extends StatelessWidget { if (child != null) return child!; - final effectiveTextStyle = - _textStyle ?? - context.themeText.paragraph!.copyWith( - fontSize: buttonFontSize, - color: disabled ? context.colors.textPrimary.useOpacity(0.5) : textColor, - fontWeight: FontWeight.w500, - ); + final effectiveTextStyle = variant == ButtonVariant.underline + ? _underlineTextStyle(context, textColor) + : _textStyle ?? + context.themeText.paragraph!.copyWith( + fontSize: buttonFontSize, + color: disabled ? context.colors.textPrimary.useOpacity(0.5) : textColor, + fontWeight: FontWeight.w500, + ); Widget content; if (_iconPlacement == IconPlacement.top) { @@ -180,4 +196,11 @@ class QuantusButton extends StatelessWidget { return Center(child: content); } + + /// Underlined link label; a [textStyle] override supplies the color (e.g. + /// accent orange), everything else stays uniform across links. + TextStyle _underlineTextStyle(BuildContext context, Color defaultColor) { + final style = context.themeText.smallParagraph!.copyWith(color: defaultColor).merge(_textStyle); + return style.copyWith(decoration: TextDecoration.underline, decorationColor: style.color); + } } diff --git a/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart b/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart index 7476f4d42..65aeab100 100644 --- a/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart @@ -171,8 +171,7 @@ class AccountMenuScreen extends ConsumerWidget { final l10n = ref.read(l10nProvider); try { await AccountsService().removeAccount(account); - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); if (context.mounted) returnToAccountsSheet(context, ref); } catch (e, st) { quantusPrint('[AccountMenu] disconnect account error: $e\n$st'); @@ -184,8 +183,7 @@ class AccountMenuScreen extends ConsumerWidget { final l10n = ref.read(l10nProvider); try { await AccountsService().removeWallet(walletIndex); - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); if (context.mounted) returnToAccountsSheet(context, ref); } catch (e, st) { quantusPrint('[AccountMenu] disconnect wallet error: $e\n$st'); diff --git a/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart b/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart index 4ace4dab2..0105a4e42 100644 --- a/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/add_hardware_account_screen.dart @@ -1,159 +1,89 @@ -import 'package:flutter/foundation.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/custom_text_field.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; -import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; -import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/account_menu_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/connect_keystone_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 AddHardwareAccountScreen extends ConsumerStatefulWidget { +/// Intro screen for adding a Keystone hardware wallet/account. +/// +/// Explains what Keystone is, then hands off to [ConnectKeystoneScreen] for +/// the on-device instructions and QR scan. +class AddHardwareAccountScreen extends ConsumerWidget { const AddHardwareAccountScreen({super.key, required this.walletIndex, this.isNewWallet = false}); final int walletIndex; final bool isNewWallet; - @override - ConsumerState createState() => _AddHardwareAccountScreenState(); -} - -class _AddHardwareAccountScreenState extends ConsumerState { - final _name = TextEditingController(text: 'Keystone Wallet'); - final _address = TextEditingController(); - - final _accountsService = AccountsService(); - final _settingsService = SettingsService(); - final _substrateService = SubstrateService(); - - bool _isSaving = false; - String? _error; - - Future _scanQRCode() async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const QrScannerPage(), fullscreenDialog: true), - ); - if (result != null && mounted) { - _address.text = result.trim(); - if (_error != null) setState(() => _error = null); - } - } - - void _fillDebugAddress() { - _address.text = AppConstants.debugTestAddress; - if (_error != null) setState(() => _error = null); - } - - Future _save() async { - final name = _name.text.trim(); - final address = _address.text.trim(); - - final l10n = ref.read(l10nProvider); - if (name.isEmpty) { - setState(() => _error = l10n.addHardwareAccountNameRequired); - return; - } - if (!_substrateService.isValidSS58Address(address)) { - setState(() => _error = l10n.addHardwareAccountInvalidAddress); - return; - } - - setState(() { - _isSaving = true; - _error = null; - }); - - try { - final nextIndex = await _settingsService.getNextFreeAccountIndex(widget.walletIndex); - final account = Account( - walletIndex: widget.walletIndex, - index: nextIndex, - name: name, - accountId: address, - accountType: AccountType.keystone, - ); - await _accountsService.addAccount(account); - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); - if (mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => AccountMenuScreen(initialAccount: account, isPostCreation: true)), - ); - } - } catch (e) { - if (mounted) setState(() => _error = e.toString()); - } finally { - if (mounted) setState(() => _isSaving = false); - } - } + static final Uri _keystoneStoreUrl = Uri.parse('https://keyst.one'); @override - void dispose() { - _name.dispose(); - _address.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = ref.watch(l10nProvider); - final title = widget.isNewWallet ? l10n.addHardwareAccountAddWallet : l10n.addHardwareAccountAddAccount; + final colors = context.colors; + final text = context.themeText; return ScaffoldBase( - appBar: V2AppBar(title: title), + appBar: V2AppBar(title: l10n.addKeystoneAppBarTitle), mainContent: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 20), - Text(title, style: context.themeText.smallTitle), - const SizedBox(height: 16), - CustomTextField( - controller: _name, - labelText: l10n.addHardwareAccountNameLabel, - hintText: widget.isNewWallet - ? l10n.addHardwareAccountNameHintWallet - : l10n.addHardwareAccountNameHintAccount, - onChanged: (_) { - if (_error != null) setState(() => _error = null); - }, + Container( + height: 218, + decoration: BoxDecoration( + border: Border.all(color: colors.textPrimary.useOpacity(0.07)), + borderRadius: BorderRadius.circular(8), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [colors.sheetBackground, colors.surfaceHero], + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset('assets/v2/keystone_hero.png', fit: BoxFit.cover, alignment: Alignment.topCenter), + ), ), - const SizedBox(height: 16), - CustomTextField( - controller: _address, - labelText: l10n.addHardwareAccountAddressLabel, - hintText: l10n.addHardwareAccountAddressHint, - onChanged: (_) { - if (_error != null) setState(() => _error = null); - }, + const SizedBox(height: 24), + Text( + l10n.addKeystoneIntroTitle, + style: text.mediumTitle?.copyWith(color: colors.textPrimary), + textAlign: TextAlign.center, ), - const SizedBox(height: 12), - QuantusButton.simple( - label: l10n.componentQrScannerTitle, - variant: ButtonVariant.secondary, - onTap: _scanQRCode, + const SizedBox(height: 8), + Text( + l10n.addKeystoneIntroSubtitle, + style: text.smallParagraph?.copyWith(color: colors.textSubtle), + textAlign: TextAlign.center, ), - if (kDebugMode) ...[ - const SizedBox(height: 12), + ], + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + children: [ QuantusButton.simple( - label: l10n.addHardwareAccountDebugFill, - variant: ButtonVariant.secondary, - onTap: _fillDebugAddress, + label: l10n.addKeystoneConnectButton, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConnectKeystoneScreen(walletIndex: walletIndex, isNewWallet: isNewWallet), + ), + ), + ), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.addKeystoneGetOneLink, + variant: ButtonVariant.underline, + textStyle: TextStyle(color: colors.accentOrange), + onTap: () => launchUrl(_keystoneStoreUrl, mode: LaunchMode.externalApplication), ), ], - if (_error != null) ...[ - const SizedBox(height: 10), - Text(_error!, style: context.themeText.tiny?.copyWith(color: Colors.red)), - ], - const Spacer(), - QuantusButton.simple(label: title, onTap: _isSaving ? null : _save, isLoading: _isSaving), - SizedBox(height: context.themeSize.bottomButtonSpacing), - ], + ), ), ); } diff --git a/mobile-app/lib/v2/screens/accounts/connect_keystone_screen.dart b/mobile-app/lib/v2/screens/accounts/connect_keystone_screen.dart new file mode 100644 index 000000000..f9e19e3a1 --- /dev/null +++ b/mobile-app/lib/v2/screens/accounts/connect_keystone_screen.dart @@ -0,0 +1,197 @@ +import 'package:flutter/foundation.dart'; +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/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/account_menu_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Second add-Keystone screen: on-device instructions, then scans the device's +/// QR code to read the account address and saves the Keystone account. +class ConnectKeystoneScreen extends ConsumerStatefulWidget { + const ConnectKeystoneScreen({super.key, required this.walletIndex, this.isNewWallet = false}); + + final int walletIndex; + final bool isNewWallet; + + @override + ConsumerState createState() => _ConnectKeystoneScreenState(); +} + +class _ConnectKeystoneScreenState extends ConsumerState { + final _accountsService = AccountsService(); + final _settingsService = SettingsService(); + final _substrateService = SubstrateService(); + + bool _isSaving = false; + String? _error; + + Future _scan() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QrScannerPage(), fullscreenDialog: true), + ); + if (result == null || !mounted) return; + final address = result.trim(); + if (!_substrateService.isValidSS58Address(address)) { + setState(() => _error = ref.read(l10nProvider).invalidAddress); + return; + } + await _save(address); + } + + Future _save(String address) async { + setState(() { + _isSaving = true; + _error = null; + }); + + try { + final nextIndex = await _settingsService.getNextFreeAccountIndex(widget.walletIndex); + final account = Account( + walletIndex: widget.walletIndex, + index: nextIndex, + name: 'Keystone Wallet', + accountId: address, + accountType: AccountType.keystone, + ); + await _accountsService.addAccount(account); + invalidateAccountProviders(ref); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => AccountMenuScreen(initialAccount: account, isPostCreation: true)), + ); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final colors = context.colors; + final text = context.themeText; + + final sectionLabelStyle = text.tiny?.copyWith( + fontFamily: AppTextTheme.fontFamilySecondary, + color: colors.textMuted, + letterSpacing: 0.88, + ); + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.addKeystoneAppBarTitle), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset('assets/v2/keystone_logo.png', width: 142, height: 32), + const SizedBox(height: 24), + Text( + l10n.addKeystoneConnectTitle, + style: text.mediumTitle?.copyWith(color: colors.textLightGray, letterSpacing: -0.48), + ), + const SizedBox(height: 8), + Text(l10n.addKeystoneConnectSubtitle, style: text.smallParagraph?.copyWith(color: colors.textSubtle)), + const SizedBox(height: 40), + Text(l10n.addKeystoneBeforeYouStart, style: sectionLabelStyle), + const SizedBox(height: 12), + _InstructionRow( + iconAsset: 'assets/v2/keystone_arrow_line_down.svg', + title: l10n.addKeystoneFirmwareTitle, + subtitle: l10n.addKeystoneFirmwareSubtitle, + ), + const SizedBox(height: 24), + Divider(height: 1, color: colors.separator), + const SizedBox(height: 24), + Text(l10n.addKeystoneOnYourKeystone, style: sectionLabelStyle), + const SizedBox(height: 12), + _InstructionRow(iconAsset: 'assets/v2/keystone_lock_simple_open.svg', title: l10n.addKeystoneStepUnlock), + _rowDivider(colors), + _InstructionRow(iconAsset: 'assets/v2/keystone_qr_code.svg', title: l10n.addKeystoneStepSelectQuantus), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: text.detail?.copyWith(color: colors.textError)), + ], + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + children: [ + QuantusButton.simple( + label: l10n.addKeystoneReadyToScan, + onTap: _isSaving ? null : _scan, + isLoading: _isSaving, + ), + if (kDebugMode) ...[ + const SizedBox(height: 12), + QuantusButton.simple( + label: 'DEBUG: Use test address', + variant: ButtonVariant.secondary, + onTap: _isSaving ? null : () => _save(AppConstants.debugTestAddress), + ), + ], + ], + ), + ), + ); + } + + Widget _rowDivider(AppColorsV2 colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1, color: colors.separator), + ); + } +} + +class _InstructionRow extends StatelessWidget { + final String iconAsset; + final String title; + final String? subtitle; + + const _InstructionRow({required this.iconAsset, required this.title, this.subtitle}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration(color: colors.sheetBackground, borderRadius: BorderRadius.circular(20)), + child: SvgPicture.asset(iconAsset, width: 16, height: 16), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: text.paragraph?.copyWith(color: colors.textPrimary, height: 1.0)), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text(subtitle!, style: text.detail?.copyWith(color: colors.textSubtle)), + ], + ], + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart index 0ffd95844..3a54cc4bd 100644 --- a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart @@ -51,8 +51,7 @@ class _CreateAccountScreenState extends ConsumerState { final accountToSave = draft.copyWith(name: _accountName.text.trim()); await _accountsService.addAccount(accountToSave); - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); ref.read(firebaseMessagingServiceProvider).insertNewAddress(accountToSave.accountId); if (mounted) returnToAccountsSheet(context, ref, highlightAccountId: accountToSave.accountId); diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 6f24a35e6..f84b6773b 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -113,8 +113,7 @@ class _ImportWalletScreenV2State extends ConsumerState { if (!HdWalletService.isDevAccount(mnemonic)) { await _discoverAccounts(mnemonic); } - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); _settingsService.setReferralCheckCompleted(); _settingsService.setExistingUserSeenPromoVideo(); _settingsService.setWalletOrigin(widget.walletIndex, WalletOrigin.imported); @@ -148,8 +147,7 @@ class _ImportWalletScreenV2State extends ConsumerState { await _accountsService.addAccount(account); } } - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); unawaited(_discoverEncryptedAccount()); } catch (e) { quantusPrint('error discovering accounts: $e'); diff --git a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart index 2f4f555eb..18b6ca009 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart @@ -303,7 +303,6 @@ class _MultisigActionConfirmSheetState extends ConsumerState widget.buildCall(signer, _callBytes), - title: widget.labels.title(l10n), primaryDetail: headline.primary, secondaryDetail: headline.secondary, cacheKey: KeystoneSignCacheKey.forExtrinsic(accountId: signer.accountId, identity: widget.hardwareCacheIdentity), diff --git a/mobile-app/lib/v2/screens/send/keystone_reject_screen.dart b/mobile-app/lib/v2/screens/send/keystone_reject_screen.dart new file mode 100644 index 000000000..9f0a5d413 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/keystone_reject_screen.dart @@ -0,0 +1,102 @@ +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/l10n_provider.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_signing_widgets.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'; + +/// Shown when the Keystone screen does not match what the app asked it to +/// sign: tells the user to reject on the device and not retry from this phone. +/// Pops with `true` once the user confirms the rejection. +class KeystoneRejectScreen extends ConsumerWidget { + const KeystoneRejectScreen({super.key}); + + static final Uri _supportUrl = Uri.parse('mailto:${AppConstants.emailSupport}'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colors; + + final steps = [ + (l10n.keystoneRejectStep1Title, l10n.keystoneRejectStep1Body), + (l10n.keystoneRejectStep2Title, l10n.keystoneRejectStep2Body), + (l10n.keystoneRejectStep3Title, l10n.keystoneRejectStep3Body), + ]; + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.keystoneSignScreenTitle), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KeystoneWarningCard(title: l10n.keystoneRejectTitle, text: l10n.keystoneVerifyWarning), + const SizedBox(height: 40), + for (var i = 0; i < steps.length; i++) ...[ + if (i > 0) ...[ + const SizedBox(height: 16), + Divider(height: 1, color: colors.separator), + const SizedBox(height: 16), + ], + _RejectStep(number: '0${i + 1}', title: steps[i].$1, body: steps[i].$2), + ], + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + children: [ + QuantusButton.simple(label: l10n.keystoneRejectConfirm, onTap: () => Navigator.pop(context, true)), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.keystoneRejectContactSupport, + variant: ButtonVariant.underline, + onTap: () => launchUrl(_supportUrl), + ), + ], + ), + ), + ); + } +} + +class _RejectStep extends StatelessWidget { + final String number; + final String title; + final String body; + + const _RejectStep({required this.number, required this.title, required this.body}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + number, + style: text.paragraph?.copyWith(fontFamily: AppTextTheme.fontFamilySecondary, color: colors.accentOrange), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: text.smallParagraph?.copyWith(color: colors.textLightGray, height: 1.4)), + const SizedBox(height: 4), + Text(body, style: text.detail?.copyWith(color: colors.textSubtle, height: 1.35)), + ], + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart index 3a4cb5f83..065055797 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; @@ -12,12 +13,14 @@ import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; -import 'package:resonance_network_wallet/v2/screens/send/keystone_signature_scan_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_signing_session.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_signing_widgets.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_verify_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; -/// Shows an unsigned transaction as a Keystone UR QR, then scans its signature. +/// Step 1/3 of Keystone signing: shows the unsigned transaction as an +/// animated UR QR for the device to scan. /// /// The runtime call, display details, caching, and submission behavior come from /// [session], so every Keystone-backed flow shares this screen. @@ -81,16 +84,20 @@ class _KeystoneSignScreenState extends ConsumerState { } } - Future _goToScan() async { + Future _goToVerify() async { final unsignedData = _unsignedData; if (unsignedData == null) return; - final hash = await Navigator.push( + final result = await Navigator.push( context, MaterialPageRoute( - builder: (_) => KeystoneSignatureScanScreen(session: widget.session, unsignedData: unsignedData), + builder: (_) => KeystoneVerifyScreen(session: widget.session, unsignedData: unsignedData), ), ); - if (hash != null && mounted) Navigator.pop(context, hash); + if (!mounted) return; + // A hash means the signature was submitted; `false` means the user rejected + // the transaction on the device — either way this flow is over. + if (result is String) Navigator.pop(context, result); + if (result == false) Navigator.pop(context); } @override @@ -101,66 +108,105 @@ class _KeystoneSignScreenState extends ConsumerState { final session = widget.session; return ScaffoldBase( - appBar: V2AppBar(title: session.title), - mainContent: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 8), - Text(l10n.keystoneSignTitle, style: text.smallTitle, textAlign: TextAlign.center), - const SizedBox(height: 8), - Text( - l10n.keystoneSignInstruction, - style: text.smallParagraph?.copyWith(color: colors.textTertiary), - textAlign: TextAlign.center, - ), - if (session.primaryDetail != null || session.secondaryDetail != null || session.tertiaryDetail != null) ...[ - const SizedBox(height: 24), - _details(colors, text, session), + appBar: V2AppBar(title: l10n.keystoneSignScreenTitle), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const KeystoneStepLabel(current: 1, total: 3), + const SizedBox(height: 12), + Text(l10n.keystoneSignTitle, style: text.paragraph?.copyWith(color: colors.textPrimary, height: 1.0)), + const SizedBox(height: 8), + Text(l10n.keystoneSignInstruction, style: text.detail?.copyWith(color: colors.textSubtle, height: 1.35)), + const SizedBox(height: 32), + Center(child: _buildQr(colors, text)), + if (session.primaryDetail != null || session.secondaryDetail != null) ...[ + const SizedBox(height: 32), + _details(colors, text, l10n, session), + ], ], - const SizedBox(height: 24), - Expanded(child: Center(child: _buildQr(colors, text))), - ], + ), ), bottomContent: ScaffoldBaseBottomContent( - child: QuantusButton.simple( - label: l10n.keystoneSignNext, - variant: ButtonVariant.primary, - isDisabled: _unsignedData == null, - onTap: _goToScan, + child: Column( + children: [ + QuantusButton.simple(label: l10n.keystoneSignNext, isDisabled: _unsignedData == null, onTap: _goToVerify), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.keystoneSignCancel, + variant: ButtonVariant.underline, + onTap: () => Navigator.pop(context), + ), + ], ), ), ); } - Widget _details(AppColorsV2 colors, AppTextTheme text, KeystoneSigningSession session) { + Widget _details(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n, KeystoneSigningSession session) { + final labelStyle = text.transactionDetailRowLabel?.copyWith(color: colors.textTertiary); + final valueStyle = text.transactionDetailRowValue?.copyWith( + color: colors.textPrimary.useOpacity(0.8), + fontWeight: FontWeight.w400, + ); + final secondary = session.secondaryDetail?.trim(); + final displaySecondary = secondary != null && SubstrateService().isValidSS58Address(secondary) + ? AddressFormattingService.formatAddress(secondary) + : secondary; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + l10n.keystoneSignYouAreSigning, + style: text.detail?.copyWith(fontFamily: AppTextTheme.fontFamilySecondary, color: colors.textPrimary), + ), + const SizedBox(height: 12), if (session.primaryDetail != null) - Text( - session.primaryDetail!, - style: text.smallTitle?.copyWith(color: colors.textPrimary), - textAlign: TextAlign.center, - ), - if (session.secondaryDetail != null) ...[ - const SizedBox(height: 8), - Text( - session.secondaryDetail!, - style: text.detail?.copyWith( - color: colors.textMuted, - fontFamily: AppTextTheme.fontFamilySecondary, - fontSize: 12, + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.sendReviewAmount.toUpperCase(), style: labelStyle), + Text(session.primaryDetail!, style: valueStyle), + ], ), - textAlign: TextAlign.center, ), - ], - if (session.tertiaryDetail != null) ...[ - const SizedBox(height: 4), - Text( - session.tertiaryDetail!, - style: text.smallParagraph?.copyWith(color: colors.checksum, height: 1.2), - textAlign: TextAlign.center, + if (session.primaryDetail != null && session.secondaryDetail != null) + Divider(height: 1, color: colors.separator), + if (secondary != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.sendReviewTo.toUpperCase(), style: labelStyle), + const SizedBox(width: 24), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + displaySecondary!, + style: text.transactionDetailRowValue?.copyWith(height: 1.35), + textAlign: TextAlign.end, + ), + if (session.tertiaryDetail != null) ...[ + const SizedBox(height: 4), + Text( + session.tertiaryDetail!, + style: text.detail?.copyWith(color: colors.checksum), + textAlign: TextAlign.end, + ), + ], + ], + ), + ), + ], + ), ), - ], ], ); } @@ -168,14 +214,25 @@ class _KeystoneSignScreenState extends ConsumerState { Widget _buildQr(AppColorsV2 colors, AppTextTheme text) { final error = _error; if (error != null) { - return Text( - error, - style: text.detail?.copyWith(color: colors.textError), - textAlign: TextAlign.center, + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Text( + error, + style: text.detail?.copyWith(color: colors.textError), + textAlign: TextAlign.center, + ), ); } final parts = _urParts; - if (parts == null) return const Loader(); - return AnimatedUrQr(parts: parts); + if (parts == null) { + return const Padding(padding: EdgeInsets.symmetric(vertical: 48), child: Loader()); + } + return Container( + decoration: BoxDecoration( + border: Border.all(color: colors.textTertiary), + borderRadius: BorderRadius.circular(14), + ), + child: AnimatedUrQr(parts: parts, size: 267), + ); } } diff --git a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart index 21122d57c..d5f674ecd 100644 --- a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart @@ -78,7 +78,11 @@ class _KeystoneSignatureScanScreenState extends ConsumerState _goToScan(BuildContext context) async { + final hash = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => KeystoneSignatureScanScreen(session: session, unsignedData: unsignedData), + ), + ); + if (hash != null && context.mounted) Navigator.pop(context, hash); + } + + Future _goToReject(BuildContext context) async { + final rejected = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const KeystoneRejectScreen()), + ); + if (rejected == true && context.mounted) Navigator.pop(context, false); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colors; + final text = context.themeText; + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.keystoneSignScreenTitle), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const KeystoneStepLabel(current: 2, total: 3), + const SizedBox(height: 12), + Text(l10n.keystoneVerifyTitle, style: text.paragraph?.copyWith(color: colors.textPrimary, height: 1.0)), + const SizedBox(height: 8), + Text(l10n.keystoneVerifyInstruction, style: text.detail?.copyWith(color: colors.textSubtle, height: 1.35)), + const SizedBox(height: 24), + _summaryCard(context, l10n, colors, text), + const SizedBox(height: 24), + KeystoneWarningCard(text: l10n.keystoneVerifyWarning), + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + children: [ + QuantusButton.simple(label: l10n.keystoneSignNext, onTap: () => _goToScan(context)), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.keystoneVerifyMismatch, + variant: ButtonVariant.underline, + onTap: () => _goToReject(context), + ), + ], + ), + ), + ); + } + + Widget _summaryCard(BuildContext context, AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { + final labelStyle = text.receiveLabel?.copyWith(color: colors.textLabel, fontSize: 14); + final address = session.secondaryDetail?.trim(); + final checksum = session.tertiaryDetail; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration(color: colors.surfaceDeep, borderRadius: BorderRadius.circular(14)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session.primaryDetail != null) ...[ + Text(l10n.sendReviewAmount.toUpperCase(), style: labelStyle), + const SizedBox(height: 16), + Text( + session.primaryDetail!, + style: text.conversionAmountPrimary?.copyWith(color: colors.textPrimary, height: 1.0), + ), + const SizedBox(height: 24), + Divider(height: 1, color: colors.separator), + const SizedBox(height: 24), + ], + if (address != null) ...[ + Text(l10n.sendReviewTo.toUpperCase(), style: labelStyle), + const SizedBox(height: 16), + if (checksum != null) + AddressCheckphraseWithInitial(recipientChecksum: checksum, recipientAddress: address) + else + Text(address, style: text.transactionDetailRowValue?.copyWith(fontSize: 14, height: 1.35)), + ], + ], + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index b18baddca..06b47d9b9 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -130,7 +130,6 @@ class RegularSendStrategy extends SendStrategy { session: KeystoneSigningSession( account: account, buildCall: () => ref.read(balancesServiceProvider).getBalanceTransferCall(recipient, amount), - title: isPayMode ? l10n.sendPayTitle : l10n.sendTitle, primaryDetail: l10n.commonAmountBalance( fmt.formatBalance(amount, smartDecimals: 4), AppConstants.tokenSymbol, diff --git a/mobile-app/lib/v2/screens/welcome/welcome_screen.dart b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart index d219f650b..b0170dfc2 100644 --- a/mobile-app/lib/v2/screens/welcome/welcome_screen.dart +++ b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart @@ -52,8 +52,7 @@ class _WelcomeScreenV2State extends ConsumerState { // Software wallets always get a companion encrypted (wormhole) account. await ensureEncryptedAccounts(ref); - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); ref.invalidate(walletOriginProvider(_walletIndex)); ref.invalidate(recoveryPhraseViewedProvider(_walletIndex)); diff --git a/mobile-app/lib/v2/theme/app_colors.dart b/mobile-app/lib/v2/theme/app_colors.dart index 0fe5841f5..f1e3cae09 100644 --- a/mobile-app/lib/v2/theme/app_colors.dart +++ b/mobile-app/lib/v2/theme/app_colors.dart @@ -15,6 +15,7 @@ class AppColorsV2 extends ThemeExtension { final Color textSecondary; final Color textTertiary; final Color textMuted; + final Color textSubtle; final Color textError; final Color textLabel; final Color textLightGray; @@ -26,6 +27,8 @@ class AppColorsV2 extends ThemeExtension { // Semantic final Color error; + final Color errorLight; + final Color errorDeep; final Color danger; final Color success; @@ -63,6 +66,9 @@ class AppColorsV2 extends ThemeExtension { final Color copyButtonCopiedBg; final Color copyButtonCopiedBorder; + // Hero cards (keystone intro) + final Color surfaceHero; + // Account tags final Color tagGuardian; final Color tagEntrusted; @@ -87,6 +93,7 @@ class AppColorsV2 extends ThemeExtension { required this.textSecondary, required this.textTertiary, required this.textMuted, + required this.textSubtle, required this.textError, required this.textLabel, required this.textLightGray, @@ -94,6 +101,8 @@ class AppColorsV2 extends ThemeExtension { required this.accentGreen, required this.checksum, required this.error, + required this.errorLight, + required this.errorDeep, required this.danger, required this.success, required this.backgroundGlow, @@ -116,6 +125,7 @@ class AppColorsV2 extends ThemeExtension { required this.surfaceDeep, required this.copyButtonCopiedBg, required this.copyButtonCopiedBorder, + required this.surfaceHero, required this.tagGuardian, required this.tagEntrusted, required this.tagHighSecurity, @@ -140,6 +150,7 @@ class AppColorsV2 extends ThemeExtension { textSecondary: const Color(0x80FFFFFF), textTertiary: const Color(0xFF3D3D3D), textMuted: const Color(0xFF888888), + textSubtle: const Color(0xFFA3A3A3), textError: const Color(0xFFC0392B), textLabel: const Color(0xFF787878), textLightGray: const Color(0xFFEBEBEB), @@ -147,6 +158,8 @@ class AppColorsV2 extends ThemeExtension { accentGreen: const Color(0xFF34C759), checksum: const Color(0xFF95A7FB), error: const Color(0xFFFF2D54), + errorLight: const Color(0xFFFF5B5B), + errorDeep: const Color(0xFF5A1B14), danger: const Color(0xFFFF1F45), success: const Color(0xFF22A27F), backgroundGlow: const Color(0xFFFFFFFF), @@ -163,6 +176,7 @@ class AppColorsV2 extends ThemeExtension { surfaceDeep: const Color(0xFF141414), copyButtonCopiedBg: const Color(0xFF0C1C14), copyButtonCopiedBorder: const Color(0xFF1A3226), + surfaceHero: const Color(0xFF141210), buttonDisabled: const Color(0xFF3D3C44), buttonDanger: const Color(0x1AFF0000), skeletonBase: const Color(0xFF161616), @@ -185,6 +199,7 @@ class AppColorsV2 extends ThemeExtension { Color? textSecondary, Color? textTertiary, Color? textMuted, + Color? textSubtle, Color? textError, Color? textLabel, Color? textLightGray, @@ -192,6 +207,8 @@ class AppColorsV2 extends ThemeExtension { Color? accentGreen, Color? checksum, Color? error, + Color? errorLight, + Color? errorDeep, Color? danger, Color? success, Color? backgroundGlow, @@ -220,6 +237,7 @@ class AppColorsV2 extends ThemeExtension { Color? surfaceDeep, Color? copyButtonCopiedBg, Color? copyButtonCopiedBorder, + Color? surfaceHero, Color? tagGuardian, Color? tagEntrusted, Color? tagHighSecurity, @@ -240,6 +258,7 @@ class AppColorsV2 extends ThemeExtension { textSecondary: textSecondary ?? this.textSecondary, textTertiary: textTertiary ?? this.textTertiary, textMuted: textMuted ?? this.textMuted, + textSubtle: textSubtle ?? this.textSubtle, textError: textError ?? this.textError, textLabel: textLabel ?? this.textLabel, textLightGray: textLightGray ?? this.textLightGray, @@ -247,6 +266,8 @@ class AppColorsV2 extends ThemeExtension { accentGreen: accentGreen ?? this.accentGreen, checksum: checksum ?? this.checksum, error: error ?? this.error, + errorLight: errorLight ?? this.errorLight, + errorDeep: errorDeep ?? this.errorDeep, danger: danger ?? this.danger, success: success ?? this.success, backgroundGlow: backgroundGlow ?? this.backgroundGlow, @@ -270,6 +291,7 @@ class AppColorsV2 extends ThemeExtension { surfaceDeep: surfaceDeep ?? this.surfaceDeep, copyButtonCopiedBg: copyButtonCopiedBg ?? this.copyButtonCopiedBg, copyButtonCopiedBorder: copyButtonCopiedBorder ?? this.copyButtonCopiedBorder, + surfaceHero: surfaceHero ?? this.surfaceHero, tagGuardian: tagGuardian ?? this.tagGuardian, tagEntrusted: tagEntrusted ?? this.tagEntrusted, tagHighSecurity: tagHighSecurity ?? this.tagHighSecurity, @@ -295,6 +317,7 @@ class AppColorsV2 extends ThemeExtension { textSecondary: Color.lerp(textSecondary, other.textSecondary, t) ?? textSecondary, textTertiary: Color.lerp(textTertiary, other.textTertiary, t) ?? textTertiary, textMuted: Color.lerp(textMuted, other.textMuted, t) ?? textMuted, + textSubtle: Color.lerp(textSubtle, other.textSubtle, t) ?? textSubtle, textError: Color.lerp(textError, other.textError, t) ?? textError, textLabel: Color.lerp(textLabel, other.textLabel, t) ?? textLabel, textLightGray: Color.lerp(textLightGray, other.textLightGray, t) ?? textLightGray, @@ -302,6 +325,8 @@ class AppColorsV2 extends ThemeExtension { accentGreen: Color.lerp(accentGreen, other.accentGreen, t) ?? accentGreen, checksum: Color.lerp(checksum, other.checksum, t) ?? checksum, error: Color.lerp(error, other.error, t) ?? error, + errorLight: Color.lerp(errorLight, other.errorLight, t) ?? errorLight, + errorDeep: Color.lerp(errorDeep, other.errorDeep, t) ?? errorDeep, danger: Color.lerp(danger, other.danger, t) ?? danger, success: Color.lerp(success, other.success, t) ?? success, backgroundGlow: Color.lerp(backgroundGlow, other.backgroundGlow, t) ?? backgroundGlow, @@ -332,6 +357,7 @@ class AppColorsV2 extends ThemeExtension { copyButtonCopiedBg: Color.lerp(copyButtonCopiedBg, other.copyButtonCopiedBg, t) ?? copyButtonCopiedBg, copyButtonCopiedBorder: Color.lerp(copyButtonCopiedBorder, other.copyButtonCopiedBorder, t) ?? copyButtonCopiedBorder, + surfaceHero: Color.lerp(surfaceHero, other.surfaceHero, t) ?? surfaceHero, tagGuardian: Color.lerp(tagGuardian, other.tagGuardian, t) ?? tagGuardian, tagEntrusted: Color.lerp(tagEntrusted, other.tagEntrusted, t) ?? tagEntrusted, tagHighSecurity: Color.lerp(tagHighSecurity, other.tagHighSecurity, t) ?? tagHighSecurity, diff --git a/mobile-app/lib/wallet_initializer.dart b/mobile-app/lib/wallet_initializer.dart index f0ccd551d..5922b2a03 100644 --- a/mobile-app/lib/wallet_initializer.dart +++ b/mobile-app/lib/wallet_initializer.dart @@ -132,8 +132,7 @@ class WalletInitializerState extends ConsumerState { } void _reloadAccounts() { - ref.invalidate(accountsProvider); - ref.invalidate(activeAccountProvider); + invalidateAccountProviders(ref); } Future _performMigration() async { diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 79993916e..694a356e8 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -102,6 +102,12 @@ flutter: - assets/v2/splash_background.jpg - assets/v2/ascii_background.png - assets/v2/quantus_orange_logo.png + - assets/v2/keystone_hero.png + - assets/v2/keystone_logo.png + - assets/v2/keystone_arrow_line_down.svg + - assets/v2/keystone_lock_simple_open.svg + - assets/v2/keystone_qr_code.svg + - assets/v2/keystone_warning.svg fonts: