diff --git a/mobile-app/assets/v2/glass_small_button_40.png b/mobile-app/assets/v2/glass_small_button_40.png deleted file mode 100644 index 3ce7c652..00000000 Binary files a/mobile-app/assets/v2/glass_small_button_40.png and /dev/null differ diff --git a/mobile-app/assets/v2/glass_tiny_button.png b/mobile-app/assets/v2/glass_tiny_button.png index 027ba8d0..f0bbe660 100644 Binary files a/mobile-app/assets/v2/glass_tiny_button.png and b/mobile-app/assets/v2/glass_tiny_button.png differ diff --git a/mobile-app/lib/features/components/account_gradient_image.dart b/mobile-app/lib/features/components/account_gradient_image.dart index dc8f4726..6bbc6e4c 100644 --- a/mobile-app/lib/features/components/account_gradient_image.dart +++ b/mobile-app/lib/features/components/account_gradient_image.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:resonance_network_wallet/utils/color_generator_engine.dart'; class AccountGradientImage extends StatelessWidget { - final dynamic accountId; - final dynamic width; - final dynamic height; + final String accountId; + final double width; + final double height; const AccountGradientImage({super.key, required this.accountId, required this.width, required this.height}); diff --git a/mobile-app/lib/features/main/screens/account_settings_screen.dart b/mobile-app/lib/features/main/screens/account_settings_screen.dart index 64539e5e..786992f1 100644 --- a/mobile-app/lib/features/main/screens/account_settings_screen.dart +++ b/mobile-app/lib/features/main/screens/account_settings_screen.dart @@ -18,7 +18,7 @@ import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart import 'package:resonance_network_wallet/features/main/screens/create_account_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/high_security/high_security_details_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/high_security/high_security_get_started_screen.dart'; -import 'package:resonance_network_wallet/features/main/screens/receive_screen.dart'; +import 'package:resonance_network_wallet/shared/utils/share_utils.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; @@ -256,9 +256,7 @@ class _AccountSettingsScreenState extends ConsumerState { Widget _buildShareSection() { return _buildSettingCard( child: InkWell( - onTap: () { - showReceiveSheet(context, isReceiving: false); - }, + onTap: () => shareAccountDetails(context, widget.account.accountId, checksum: widget.checksumName), child: Padding( padding: const EdgeInsets.only(left: 10.0, top: 8.5, bottom: 8.5, right: 18.0), child: Row( diff --git a/mobile-app/lib/features/main/screens/receive_screen.dart b/mobile-app/lib/features/main/screens/receive_screen.dart index 2ab6b7a7..b1f7e995 100644 --- a/mobile-app/lib/features/main/screens/receive_screen.dart +++ b/mobile-app/lib/features/main/screens/receive_screen.dart @@ -12,7 +12,7 @@ import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; -import 'package:share_plus/share_plus.dart'; +import 'package:resonance_network_wallet/shared/utils/share_utils.dart'; class ReceiveSheet extends StatefulWidget { final bool isReceiving; @@ -69,17 +69,8 @@ class _ReceiveSheetState extends State { } void _share() { - if (_accountId != null && _checksum != null) { - final textToShare = - 'Hey! These are my Quantus account details:\n\nAddress:\n$_accountId\n\nCheckphrase:$_checksum\n\nTo open in the app or to download click the link below:\n${AppConstants.websiteBaseUrl}/account?id=$_accountId'; - SharePlus.instance.share( - ShareParams( - text: textToShare, - subject: 'Shared Address', - title: 'Shared Address', - sharePositionOrigin: context.sharePositionRect(), - ), - ); + if (_accountId != null) { + shareAccountDetails(context, _accountId!, checksum: _checksum ?? ''); } } @@ -164,7 +155,7 @@ class _ReceiveSheetState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ AccountGradientImage( - accountId: _accountId, + accountId: _accountId!, width: context.isTablet ? 32.0 : 24.0, height: context.isTablet ? 32.0 : 24.0, ), diff --git a/mobile-app/lib/features/main/screens/settings_screen.dart b/mobile-app/lib/features/main/screens/settings_screen.dart index e07abeb5..d83680f2 100644 --- a/mobile-app/lib/features/main/screens/settings_screen.dart +++ b/mobile-app/lib/features/main/screens/settings_screen.dart @@ -8,7 +8,7 @@ import 'package:resonance_network_wallet/features/components/reset_confirmation_ import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; -import 'package:resonance_network_wallet/features/main/screens/accounts_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/home/accounts_sheet.dart'; import 'package:resonance_network_wallet/features/main/screens/authentication_settings_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/notifications_settings_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/select_wallet_for_recovery_phrase_screen.dart'; @@ -159,7 +159,7 @@ class _SettingsScreenState extends ConsumerState { ListItem( title: 'Manage Accounts', onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountsScreen())); + showAccountsSheet(context); }, ), const SizedBox(height: 22), diff --git a/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart b/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart index 786eec7b..9d087765 100644 --- a/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart +++ b/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart @@ -7,7 +7,7 @@ import 'package:resonance_network_wallet/features/components/shared_address_acti import 'package:resonance_network_wallet/features/components/skeleton.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; -import 'package:resonance_network_wallet/features/main/screens/accounts_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/home/accounts_sheet.dart'; import 'package:resonance_network_wallet/features/main/screens/receive_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/notifications_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/send/send_screen.dart'; @@ -127,7 +127,7 @@ class _WalletMainState extends ConsumerState { InkWell( child: SvgPicture.asset('assets/wallet_icon.svg', width: 26, height: 26), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const AccountsScreen())); + showAccountsSheet(context); }, ), ], diff --git a/mobile-app/lib/shared/utils/share_utils.dart b/mobile-app/lib/shared/utils/share_utils.dart new file mode 100644 index 00000000..b75b6aee --- /dev/null +++ b/mobile-app/lib/shared/utils/share_utils.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:share_plus/share_plus.dart'; + +String buildAccountShareText(String accountId, {required String checksum}) { + final checkphrasePart = '\n\nCheckphrase:$checksum'; + + return 'Hey! These are my Quantus account details:\n\nAddress:\n$accountId$checkphrasePart\n\nTo open in the app or to download click the link below:\n${AppConstants.websiteBaseUrl}/account?id=$accountId'; +} + +void shareAccountDetails(BuildContext context, String accountId, {required String checksum}) { + SharePlus.instance.share( + ShareParams( + text: buildAccountShareText(accountId, checksum: checksum), + subject: 'Shared Address', + title: 'Shared Address', + sharePositionOrigin: context.sharePositionRect(), + ), + ); +} diff --git a/mobile-app/lib/v2/components/glass_container.dart b/mobile-app/lib/v2/components/glass_container.dart index 32f384af..e1f0e4ad 100644 --- a/mobile-app/lib/v2/components/glass_container.dart +++ b/mobile-app/lib/v2/components/glass_container.dart @@ -12,6 +12,7 @@ class GlassContainer extends StatelessWidget { 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_40.png'; + static const tinyAsset = 'assets/v2/glass_tiny_button.png'; static const wideAsset = 'assets/v2/glass_wide_clear.png'; static const wideClearAsset = 'assets/v2/glass_wide_clear.png'; @@ -29,6 +30,12 @@ class GlassContainer extends StatelessWidget { ? 36 : 56; + double get defaultRadius => asset == tinyAsset + ? 4 + : asset == smallAsset + ? 8 + : 14; + const GlassContainer({ super.key, required this.child, @@ -44,7 +51,7 @@ class GlassContainer extends StatelessWidget { return GestureDetector( onTap: onTap, child: ClipRRect( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(defaultRadius), child: SizedBox( height: defaultHeight, child: Stack( diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart new file mode 100644 index 00000000..885ccdec --- /dev/null +++ b/mobile-app/lib/v2/components/token_icon.dart @@ -0,0 +1,99 @@ +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'; + +class TokenIcon extends StatelessWidget { + final SwapToken token; + final double size; + final double networkBadgeSize; + + const TokenIcon({super.key, required this.token, this.size = 31, this.networkBadgeSize = 12}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final swapService = SwapService(); + final iconUrl = token.iconUrl ?? swapService.getTokenIconUrl(token); + final networkIconUrl = token.networkIconUrl ?? swapService.getNetworkIconUrl(token); + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: ClipOval( + child: iconUrl != null + ? Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _fallback(context, token, colors, text), + ) + : _fallback(context, token, colors, text), + ), + ), + Positioned( + right: -2, + bottom: -2, + child: SizedBox( + width: networkBadgeSize, + height: networkBadgeSize, + child: ClipOval( + child: networkIconUrl != null + ? Image.network( + networkIconUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _networkFallback(context, token, colors, text), + ) + : _networkFallback(context, token, colors, text), + ), + ), + ), + ], + ), + ); + } + + Widget _fallback(BuildContext context, SwapToken token, AppColorsV2 colors, AppTextTheme text) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF2F86E8), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF71B5FF), width: 1.4), + ), + child: Center( + child: Text( + token.symbol.isNotEmpty ? token.symbol.substring(0, 1) : '?', + style: text.tiny?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + ); + } + + Widget _networkFallback(BuildContext context, SwapToken token, AppColorsV2 colors, AppTextTheme text) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + ), + child: Center( + child: Text( + token.network.isNotEmpty ? token.network.substring(0, 1) : '?', + style: text.tiny?.copyWith( + color: colors.textPrimary, + fontSize: 8, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/activity/activity_screen.dart b/mobile-app/lib/v2/screens/activity/activity_screen.dart index 5e500c15..4106683f 100644 --- a/mobile-app/lib/v2/screens/activity/activity_screen.dart +++ b/mobile-app/lib/v2/screens/activity/activity_screen.dart @@ -38,7 +38,8 @@ class ActivityScreen extends ConsumerWidget { 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(width: 24), // empty filler + // Icon(Icons.info_outline, color: colors.textPrimary, size: 24), ], ), const SizedBox(height: 48), diff --git a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart index c24f2f6b..3166dbff 100644 --- a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -4,6 +4,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.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/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:url_launcher/url_launcher.dart'; @@ -178,12 +179,9 @@ class _TransactionDetailSheetState extends State<_TransactionDetailSheet> { 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: GlassContainer( + asset: GlassContainer.wideAsset, + filled: false, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/mobile-app/lib/v2/screens/home/accounts_sheet.dart b/mobile-app/lib/v2/screens/home/accounts_sheet.dart new file mode 100644 index 00000000..9b8de52f --- /dev/null +++ b/mobile-app/lib/v2/screens/home/accounts_sheet.dart @@ -0,0 +1,804 @@ +import 'dart:math' as math; + +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/account_gradient_image.dart'; +import 'package:resonance_network_wallet/features/components/app_modal_bottom_sheet.dart'; +import 'package:resonance_network_wallet/features/main/screens/add_hardware_account_screen.dart'; +import 'package:resonance_network_wallet/shared/utils/share_utils.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/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; + +Future showAccountsSheet(BuildContext context) { + return showAppModalBottomSheet(context: context, builder: (_) => const AccountsSheet()); +} + +class AccountsSheet extends ConsumerStatefulWidget { + const AccountsSheet({super.key}); + + @override + ConsumerState createState() => _AccountsScreenState(); +} + +class _AccountsScreenState extends ConsumerState { + final AccountsService _accountsService = AccountsService(); + final NumberFormattingService _formattingService = NumberFormattingService(); + final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _createNameController = TextEditingController(); + + bool _isCreatingAccount = false; + bool _isSavingCreatedAccount = false; + bool _isEditingName = false; + bool _isEditingCreatedName = false; + bool _isSavingName = false; + bool _isCreateViewOpen = false; + String? _editingAccountId; + Account? _draftAccount; + String _draftChecksum = 'Loading...'; + + bool _isHardwareWallet(List accounts) { + return accounts.isNotEmpty && accounts.every((a) => a.accountType == AccountType.keystone); + } + + int _walletIndexForActiveAccount(List accounts, DisplayAccount? activeDisplayAccount) { + if (activeDisplayAccount is RegularAccount) { + return activeDisplayAccount.account.walletIndex; + } + + if (activeDisplayAccount is EntrustedDisplayAccount) { + final parent = accounts.firstWhereOrNull((a) => a.accountId == activeDisplayAccount.account.parentAccountId); + if (parent != null) return parent.walletIndex; + } + + return accounts.isNotEmpty ? accounts.first.walletIndex : 0; + } + + List _displayAccounts(List accounts) { + final sorted = [...accounts]; + sorted.sort((a, b) { + final walletCmp = a.walletIndex.compareTo(b.walletIndex); + if (walletCmp != 0) return walletCmp; + return a.index.compareTo(b.index); + }); + return sorted; + } + + Future _createNewAccount() async { + if (_isCreatingAccount) return; + + setState(() => _isCreatingAccount = true); + try { + final accounts = ref.read(accountsProvider).value ?? []; + final activeDisplayAccount = ref.read(activeAccountProvider).value; + final walletIndex = _walletIndexForActiveAccount(accounts, activeDisplayAccount); + final selectedWalletAccounts = accounts.where((a) => a.walletIndex == walletIndex).toList(); + + if (_isHardwareWallet(selectedWalletAccounts)) { + final created = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => AddHardwareAccountScreen(walletIndex: walletIndex)), + ); + if (created == true) { + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + } + } else { + final draft = await _accountsService.createNewAccount(walletIndex: walletIndex); + final checksum = await _checksumService.getHumanReadableName(draft.accountId); + if (!mounted) return; + _createNameController.text = draft.name; + setState(() { + _draftAccount = draft; + _draftChecksum = checksum; + _isCreateViewOpen = true; + _isEditingCreatedName = false; + }); + } + } catch (_) { + if (mounted) { + context.showErrorToaster(message: 'Could not add account.'); + } + } finally { + if (mounted) { + setState(() => _isCreatingAccount = false); + } + } + } + + Future _switchAccount(Account account) async { + await ref.read(activeAccountProvider.notifier).setActiveAccount(RegularAccount(account)); + if (mounted) { + Navigator.of(context).pop(); + } + } + + Future _openEdit(Account account) async { + _nameController.text = account.name; + setState(() { + _editingAccountId = account.accountId; + _isEditingName = false; + }); + } + + void _closeEdit() { + setState(() { + _editingAccountId = null; + _isEditingName = false; + _isSavingName = false; + }); + } + + void _closeCreateView() { + setState(() { + _isCreateViewOpen = false; + _isEditingCreatedName = false; + _isSavingCreatedAccount = false; + _draftAccount = null; + _draftChecksum = 'Loading...'; + _createNameController.clear(); + }); + } + + Future _saveEditedName(Account account) async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + context.showErrorToaster(message: "Account name can't be empty"); + return; + } + if (name == account.name) { + setState(() => _isEditingName = false); + return; + } + + setState(() => _isSavingName = true); + try { + await _accountsService.updateAccountName(account, name); + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + if (mounted) { + setState(() { + _isEditingName = false; + }); + } + } catch (_) { + if (mounted) { + context.showErrorToaster(message: 'Failed to rename account.'); + } + } finally { + if (mounted) { + setState(() => _isSavingName = false); + } + } + } + + @override + void dispose() { + _nameController.dispose(); + _createNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final maxHeight = media.size.height - media.padding.top - 20; + final sheetHeight = math.min(610.0, maxHeight); + + final accountsAsync = ref.watch(accountsProvider); + final activeDisplayAccountAsync = ref.watch(activeAccountProvider); + + final accounts = accountsAsync.value ?? []; + final activeDisplayAccount = activeDisplayAccountAsync.value; + final displayAccounts = _displayAccounts(accounts); + final activeAccountId = activeDisplayAccount?.account.accountId; + final editingAccount = _editingAccountId == null + ? null + : displayAccounts.firstWhereOrNull((a) => a.accountId == _editingAccountId); + + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 20), + child: SizedBox( + height: sheetHeight, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 40, 24, 40), + decoration: BoxDecoration( + color: context.colors.sheetBackground, + border: Border.all(color: context.colors.toasterBorder), + borderRadius: BorderRadius.circular(24), + ), + child: _buildContent( + accountsAsync: accountsAsync, + activeDisplayAccountAsync: activeDisplayAccountAsync, + displayAccounts: displayAccounts, + activeAccountId: activeAccountId, + editingAccount: editingAccount, + ), + ), + ), + ), + ); + } + + Widget _buildContent({ + required AsyncValue> accountsAsync, + required AsyncValue activeDisplayAccountAsync, + required List displayAccounts, + required String? activeAccountId, + required Account? editingAccount, + }) { + if (accountsAsync.isLoading || activeDisplayAccountAsync.isLoading) { + return const Center(child: CircularProgressIndicator(color: Colors.white)); + } + + if (accountsAsync.hasError) { + return Center( + child: Text( + 'Failed to load accounts.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ); + } + + if (activeDisplayAccountAsync.hasError) { + return Center( + child: Text( + 'Failed to load active account.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ); + } + + if (_isCreateViewOpen && _draftAccount != null) { + return _buildCreateAccountView(); + } + + if (editingAccount != null) { + return _buildEditAccountView(editingAccount); + } + + return _buildAccountsListView(displayAccounts, activeAccountId); + } + + Widget _buildAccountsListView(List displayAccounts, String? activeAccountId) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + SizedBox( + width: 32.0, + height: 32.0, + child: activeAccountId == null + ? const SizedBox() + : AccountGradientImage(accountId: activeAccountId, width: 32.0, height: 32.0), + ), + const SizedBox(width: 12), + const Text( + 'Accounts', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 20, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1, + ), + ), + ], + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white, size: 20), + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minHeight: 20, minWidth: 20), + ), + ], + ), + const SizedBox(height: 40), + Expanded( + child: displayAccounts.isEmpty + ? Center( + child: Text( + 'No accounts found.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ) + : ListView.separated( + itemCount: displayAccounts.length, + separatorBuilder: (_, _) => const SizedBox(height: 20), + itemBuilder: (_, index) { + final account = displayAccounts[index]; + return _buildAccountRow(account, account.accountId == activeAccountId); + }, + ), + ), + const SizedBox(height: 24), + _buildPrimarySheetButton(label: 'Add Account', isLoading: _isCreatingAccount, onTap: _createNewAccount), + ], + ); + } + + Widget _buildEditAccountView(Account account) { + if (!_isEditingName && _nameController.text != account.name) { + _nameController.text = account.name; + } + + return FutureBuilder( + future: _checksumService.getHumanReadableName(account.accountId), + builder: (context, snapshot) { + final checksum = snapshot.connectionState == ConnectionState.done ? (snapshot.data ?? '-') : 'Loading...'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: _closeEdit, + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 20), + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minHeight: 20, minWidth: 20), + ), + const Text( + 'Edit Account', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 20, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white, size: 20), + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minHeight: 20, minWidth: 20), + ), + ], + ), + const SizedBox(height: 40), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Account Name', style: context.themeText.smallParagraph?.copyWith(color: Colors.white)), + const SizedBox(height: 12), + _buildAccountNameField(account), + const SizedBox(height: 40), + Text('Address Details', style: context.themeText.smallParagraph?.copyWith(color: Colors.white)), + const SizedBox(height: 12), + _buildAddressDetails(account, checksum), + ], + ), + ), + ), + const SizedBox(height: 24), + _buildPrimarySheetButton( + label: 'Share Account Details', + onTap: () => shareAccountDetails(context, account.accountId, checksum: checksum), + ), + ], + ); + }, + ); + } + + Widget _buildCreateAccountView() { + final draft = _draftAccount!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSheetHeader(title: 'New Account', onBack: _closeCreateView), + const SizedBox(height: 40), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Wallet Name', style: context.themeText.smallParagraph?.copyWith(color: Colors.white)), + const SizedBox(height: 12), + _buildCreatedNameField(), + const SizedBox(height: 40), + Text('Wallet Address', style: context.themeText.smallParagraph?.copyWith(color: Colors.white)), + const SizedBox(height: 12), + _buildCreateField( + value: AddressFormattingService.formatAddress(draft.accountId), + onCopy: () => context.copyTextWithToaster(draft.accountId), + textStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1.35, + ), + ), + const SizedBox(height: 40), + Text('Wallet Checkphrase', style: context.themeText.smallParagraph?.copyWith(color: Colors.white)), + const SizedBox(height: 12), + _buildCreateField( + value: _draftChecksum, + onCopy: () => context.copyTextWithToaster(_draftChecksum), + textStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w400, + color: context.colors.accentPink, + height: 1.0, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + _buildPrimarySheetButton( + label: 'Create Account', + isLoading: _isSavingCreatedAccount, + onTap: _submitCreatedAccount, + ), + ], + ); + } + + Widget _buildAccountRow(Account account, bool isActive) { + final balanceAsync = ref.watch(balanceProviderFamily(account.accountId)); + final balanceText = balanceAsync.when( + loading: () => 'Loading...', + error: (_, _) => 'Balance unavailable', + data: (balance) => '${_formattingService.formatBalance(balance)} ${AppConstants.tokenSymbol}', + ); + + return GestureDetector( + onTap: () => _switchAccount(account), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: context.colors.borderSubtle, width: 0.9), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.name, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w500, + color: isActive ? context.colors.accentPink : Colors.white, + height: 1.35, + ), + ), + const SizedBox(height: 4), + Text( + balanceText, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w400, + color: context.colors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + _buildIconActionButton(icon: Icons.edit_outlined, iconSize: 20, onTap: () => _openEdit(account)), + ], + ), + ), + ); + } + + Widget _buildAccountNameField(Account account) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + decoration: BoxDecoration(color: context.colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + readOnly: !_isEditingName || _isSavingName, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: context.colors.accentPink, + height: 1.35, + ), + cursorColor: context.colors.accentPink, + decoration: const InputDecoration( + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onSubmitted: (_) { + if (_isEditingName && !_isSavingName) { + _saveEditedName(account); + } + }, + onTap: () { + if (!_isEditingName) { + setState(() => _isEditingName = true); + } + }, + ), + ), + _isSavingName + ? const SizedBox( + width: 40, + height: 40, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ), + ) + : _buildIconActionButton( + icon: _isEditingName ? Icons.check : Icons.edit_outlined, + iconSize: 20, + onTap: () { + if (_isEditingName) { + _saveEditedName(account); + } else { + setState(() => _isEditingName = true); + } + }, + ), + ], + ), + ); + } + + Widget _buildCreatedNameField() { + return Container( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + decoration: BoxDecoration(color: context.colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _createNameController, + readOnly: !_isEditingCreatedName || _isSavingCreatedAccount, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white, + ), + cursorColor: Colors.white, + decoration: const InputDecoration( + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + _buildIconActionButton( + icon: _isEditingCreatedName ? Icons.check : Icons.edit_outlined, + iconSize: 20, + onTap: () { + setState(() => _isEditingCreatedName = !_isEditingCreatedName); + }, + ), + ], + ), + ); + } + + Widget _buildCreateField({required String value, required VoidCallback onCopy, required TextStyle textStyle}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + decoration: BoxDecoration(color: context.colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), + child: Row( + children: [ + Expanded( + child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: textStyle), + ), + const SizedBox(width: 8), + _buildIconActionButton(icon: Icons.copy_outlined, iconSize: 20, onTap: onCopy), + ], + ), + ); + } + + Widget _buildSheetHeader({required String title, VoidCallback? onBack}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 24, + height: 24, + child: onBack == null + ? const SizedBox() + : IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 20), + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minHeight: 24, minWidth: 24), + ), + ), + Text( + title, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 20, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1, + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white, size: 20), + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minHeight: 24, minWidth: 24), + ), + ), + ], + ); + } + + Widget _buildAddressDetails(Account account, String checksum) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: context.colors.surfaceGlass, borderRadius: BorderRadius.circular(14)), + child: Column( + children: [ + _buildCopyRow( + value: account.accountId, + onCopy: () => context.copyTextWithToaster(account.accountId), + textStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1.35, + ), + maxLines: null, + overflow: TextOverflow.visible, + ), + const SizedBox(height: 8), + _buildCopyRow( + value: checksum, + onCopy: () => context.copyTextWithToaster(checksum), + textStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w400, + color: context.colors.accentPink, + height: 1, + ), + ), + ], + ), + ); + } + + Widget _buildCopyRow({ + required String value, + required VoidCallback onCopy, + required TextStyle textStyle, + int? maxLines = 1, + TextOverflow overflow = TextOverflow.ellipsis, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(value, maxLines: maxLines, overflow: overflow, style: textStyle), + ), + const SizedBox(width: 8), + _buildIconActionButton(icon: Icons.copy_outlined, isTiny: true, iconSize: 12, onTap: onCopy), + ], + ); + } + + Widget _buildIconActionButton({ + required IconData icon, + required double iconSize, + required VoidCallback onTap, + bool isTiny = false, + }) { + final double size = isTiny ? 20 : 40; + final asset = isTiny ? GlassContainer.tinyAsset : GlassContainer.smallAsset; + return SizedBox( + width: size, + height: size, + child: GlassContainer( + asset: asset, + onTap: onTap, + child: Icon(icon, color: Colors.white, size: iconSize), + ), + ); + } + + Widget _buildPrimarySheetButton({required String label, required VoidCallback onTap, bool isLoading = false}) { + return GlassContainer( + asset: GlassContainer.wideAsset, + filled: true, + onTap: isLoading ? null : onTap, + child: isLoading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1, + ), + ), + ); + } + + Future _submitCreatedAccount() async { + final draft = _draftAccount; + if (draft == null) return; + final name = _createNameController.text.trim(); + if (name.isEmpty) { + context.showErrorToaster(message: "Account name can't be empty"); + return; + } + + setState(() => _isSavingCreatedAccount = true); + try { + final accountToSave = draft.copyWith(name: name); + await _accountsService.addAccount(accountToSave); + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + if (mounted) { + _closeCreateView(); + } + } catch (_) { + if (mounted) { + context.showErrorToaster(message: 'Failed to create account.'); + } + } finally { + if (mounted) { + setState(() => _isSavingCreatedAccount = false); + } + } + } +} diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 66ddd0b2..5f28e255 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -6,7 +6,7 @@ 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/home/accounts_sheet.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'; @@ -144,7 +144,7 @@ class _HomeScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ GestureDetector( - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen())), + onTap: () => showAccountsSheet(context), child: AccountGradientImage(accountId: active.account.accountId, width: 40.0, height: 40.0), ), Row( @@ -206,10 +206,10 @@ class _HomeScreenState extends ConsumerState { ), 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)), - ], + // if (!isBalanceHidden) ...[ + // const SizedBox(height: 6), + // Text('≈ \$0.00', style: text.paragraph?.copyWith(color: colors.textSecondary)), + // ], ], ), ); diff --git a/mobile-app/lib/v2/screens/receive/receive_sheet.dart b/mobile-app/lib/v2/screens/receive/receive_sheet.dart index daa272d0..c948378f 100644 --- a/mobile-app/lib/v2/screens/receive/receive_sheet.dart +++ b/mobile-app/lib/v2/screens/receive/receive_sheet.dart @@ -4,9 +4,9 @@ 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/shared/utils/share_utils.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}); @@ -55,18 +55,7 @@ class _ReceiveSheetState extends State { 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(), - ), - ); + shareAccountDetails(context, _accountId!, checksum: _checksum ?? ''); } } diff --git a/mobile-app/lib/v2/screens/settings/settings_screen.dart b/mobile-app/lib/v2/screens/settings/settings_screen.dart index 9a26d5e1..5ca3374f 100644 --- a/mobile-app/lib/v2/screens/settings/settings_screen.dart +++ b/mobile-app/lib/v2/screens/settings/settings_screen.dart @@ -34,7 +34,7 @@ class _SettingsScreenV2State extends ConsumerState { bool _biometricEnabled = false; String _biometricDesc = 'Face ID Disabled'; int _autoLockMinutes = 5; - bool _reversibleEnabled = false; + // bool _reversibleEnabled = false; int _reversibleTimeSeconds = 600; bool _hasPinSet = false; @@ -55,7 +55,7 @@ class _SettingsScreenV2State extends ConsumerState { final bioDesc = await _authService.getBiometricDescription(); final timeout = _authService.getAuthTimeoutMinutes(); final revTime = await _settingsService.getReversibleTimeSeconds() ?? 600; - final revEnabled = _settingsService.isReversibleEnabled(); + // final revEnabled = _settingsService.isReversibleEnabled(); if (!mounted) return; setState(() { @@ -63,7 +63,7 @@ class _SettingsScreenV2State extends ConsumerState { _biometricDesc = bioEnabled ? bioDesc : 'Face ID Disabled'; _autoLockMinutes = timeout; _reversibleTimeSeconds = revTime; - _reversibleEnabled = revEnabled; + // _reversibleEnabled = revEnabled; }); } @@ -202,14 +202,15 @@ class _SettingsScreenV2State extends ConsumerState { ]), const SizedBox(height: 40), _section('Reversible Transactions', colors, text, [ - _toggleItem( - 'Reversible Transactions', - 'Coming Soon', //_reversibleEnabled ? 'Enabled' : 'Disabled', - _reversibleEnabled, - null, - colors, - text, - ), + _comingSoonItem('Reversible Transactions', null, 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), @@ -285,13 +286,13 @@ class _SettingsScreenV2State extends ConsumerState { ); } - Column _itemContent(String title, AppTextTheme text, AppColorsV2 colors, String subtitle) { + 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)), + if (subtitle != null) const SizedBox(height: 4), + if (subtitle != null) Text(subtitle, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), ], ); } @@ -354,7 +355,7 @@ class _SettingsScreenV2State extends ConsumerState { ); } - Widget _comingSoonItem(String title, String subtitle, AppColorsV2 colors, AppTextTheme text) { + Widget _comingSoonItem(String title, String? subtitle, AppColorsV2 colors, AppTextTheme text) { return Row( children: [ Expanded(child: _itemContent(title, text, colors, subtitle)), diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/deposit_screen.dart index 662d87c8..6a33d151 100644 --- a/mobile-app/lib/v2/screens/swap/deposit_screen.dart +++ b/mobile-app/lib/v2/screens/swap/deposit_screen.dart @@ -4,6 +4,7 @@ import 'package:quantus_sdk/quantus_sdk.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/token_icon.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'; @@ -116,7 +117,7 @@ class _DepositScreenState extends State { const SizedBox(width: 6), GestureDetector( onTap: () => context.copyTextWithToaster( - quote.totalAmount.toStringAsFixed(2), + SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken), message: 'Deposit amount copied to clipboard', ), child: Container( @@ -132,14 +133,10 @@ class _DepositScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration(color: colors.accentPink.withValues(alpha: 0.3), shape: BoxShape.circle), - ), + TokenIcon(token: quote.fromToken, size: 28, networkBadgeSize: 11), const SizedBox(width: 8), Text( - quote.totalAmount.toStringAsFixed(2), + SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken), style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), ), ], @@ -281,7 +278,7 @@ class _DepositScreenState extends State { 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.', + 'Your swap for ${SwapService.formatTokenAmount(_order.quote.toAmount, _order.quote.toToken)} QUAN is processing.', style: text.paragraph?.copyWith(color: colors.textSecondary), textAlign: TextAlign.center, ), diff --git a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart index d1945fd6..60b7d288 100644 --- a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart +++ b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart @@ -2,6 +2,7 @@ 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/components/token_icon.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'; @@ -54,11 +55,16 @@ class _ReviewQuoteContent extends StatelessWidget { 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), + _feeRow( + 'Total fees', + '${SwapService.formatTokenAmount(quote.networkFee, quote.fromToken)} ${quote.fromToken.symbol}', + colors, + text, + ), Divider(color: colors.separator, height: 32), _feeRow( 'Total Amount', - '${quote.totalAmount.toStringAsFixed(2)} ${quote.fromToken.symbol}', + '${SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken)} ${quote.fromToken.symbol}', colors, text, highlight: true, @@ -89,7 +95,6 @@ class _ReviewQuoteContent extends StatelessWidget { } 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, @@ -101,14 +106,7 @@ class _ReviewQuoteContent extends StatelessWidget { 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, - ), - ), + TokenIcon(token: token, size: 22, networkBadgeSize: 9), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -124,7 +122,7 @@ class _ReviewQuoteContent extends StatelessWidget { ), const SizedBox(height: 6), Text( - amount.toStringAsFixed(2), + SwapService.formatTokenAmount(amount, token), style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), ), const SizedBox(height: 0), diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 0127650f..7f1317cd 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -4,6 +4,7 @@ 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/token_icon.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'; @@ -34,7 +35,22 @@ class _SwapScreenState extends State { bool _loading = false; double get _rate => _swapService.getRate(_fromToken); - String get _rateLabel => '1 QUAN = ${(1 / _rate).toStringAsFixed(4)} ${_fromToken.symbol}'; + String get _rateLabel { + final val = 1 / _rate; + if (val == 0) return '1 QUAN = 0 ${_fromToken.symbol}'; + final decimals = val >= 100 + ? 2 + : val >= 1 + ? 4 + : val >= 0.01 + ? 6 + : val >= 0.0001 + ? 8 + : 10; + var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); + if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1); + return '1 QUAN = $formatted ${_fromToken.symbol}'; + } @override void initState() { @@ -80,7 +96,12 @@ class _SwapScreenState extends State { } void _pickToken() async { - final token = await showTokenPickerSheet(context, _swapService.getFromTokens(), _fromToken); + final token = await showTokenPickerSheet( + context, + current: _fromToken, + loadTokens: ({bool forceRefresh = false}) => _swapService.getFromTokens(limit: 10, forceRefresh: forceRefresh), + ); + if (!mounted) return; if (token != null && token != _fromToken) { setState(() => _fromToken = token); _recalculate(); @@ -176,7 +197,7 @@ class _SwapScreenState extends State { style: text.mediumTitle?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.bold), keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( - hintText: '0.00', + hintText: SwapService.formatTokenAmountHint(_fromToken), hintStyle: text.mediumTitle?.copyWith(color: colors.textTertiary, fontWeight: FontWeight.bold), border: InputBorder.none, isDense: true, @@ -198,14 +219,7 @@ class _SwapScreenState extends State { 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, - ), - ), + TokenIcon(token: _fromToken, size: 25, networkBadgeSize: 10), const SizedBox(width: 8), Expanded( child: Column( @@ -348,7 +362,7 @@ class _SwapScreenState extends State { decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(8)), alignment: Alignment.centerLeft, child: Text( - _toAmount > 0 ? _toAmount.toStringAsFixed(2) : '0.00', + SwapService.formatTokenAmount(_toAmount, _swapService.getQuToken()), style: text.mediumTitle?.copyWith( fontWeight: FontWeight.bold, color: _toAmount > 0 ? colors.textPrimary : colors.textTertiary, @@ -449,34 +463,87 @@ class _SwapScreenState extends State { } } -class _QrScanPage extends StatelessWidget { +class _QrScanPage extends StatefulWidget { final ValueChanged onScanned; const _QrScanPage({required this.onScanned}); + @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) { final colors = context.colors; + final frameSize = (MediaQuery.of(context).size.width - 111.5).clamp(220.0, 280.0); + return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ MobileScanner( + controller: _controller, onDetect: (capture) { + if (_scanned) return; final code = capture.barcodes.firstOrNull?.rawValue; - if (code != null) onScanned(code); + if (code != null && code.isNotEmpty) { + _scanned = true; + widget.onScanned(code); + } }, ), + ColoredBox(color: Colors.black.withValues(alpha: 0.24)), + Center(child: _ScanFrame(size: frameSize)), + Positioned( + left: 0, + right: 0, + bottom: 228, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _scanActionButton(icon: Icons.image_outlined, onTap: () {}, colors: colors), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: _controller, + builder: (_, state, _) { + final isOn = state.torchState == TorchState.on; + return _scanActionButton( + icon: isOn ? Icons.flash_on : Icons.flash_off, + onTap: _controller.toggleTorch, + colors: colors, + ); + }, + ), + ], + ), + ), Positioned( - bottom: 60, + bottom: 58, 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)), + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + border: Border.all(color: Colors.white.withValues(alpha: 0.44), width: 0.9), + borderRadius: BorderRadius.circular(14), + ), child: Center( - child: Text('Cancel', style: TextStyle(color: colors.textPrimary, fontSize: 16)), + child: Text( + 'Cancel', + style: TextStyle(color: colors.textPrimary, fontSize: 16, fontWeight: FontWeight.w500), + ), ), ), ), @@ -485,4 +552,67 @@ class _QrScanPage extends StatelessWidget { ), ); } + + Widget _scanActionButton({required IconData icon, required VoidCallback onTap, required AppColorsV2 colors}) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8)), + child: Icon(icon, size: 20, color: colors.textPrimary), + ), + ); + } +} + +class _ScanFrame extends StatelessWidget { + final double size; + const _ScanFrame({required this.size}); + + @override + Widget build(BuildContext context) { + final corner = Colors.white.withValues(alpha: 0.92); + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + _corner(top: true, left: true, color: corner), + _corner(top: true, left: false, color: corner), + _corner(top: false, left: true, color: corner), + _corner(top: false, left: false, color: corner), + ], + ), + ); + } + + Widget _corner({required bool top, required bool left, required Color color}) { + return Positioned( + top: top ? 0 : null, + bottom: top ? null : 0, + left: left ? 0 : null, + right: left ? null : 0, + child: SizedBox( + width: 41, + height: 41, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: top && left ? const Radius.circular(16) : Radius.zero, + topRight: top && !left ? const Radius.circular(16) : Radius.zero, + bottomLeft: !top && left ? const Radius.circular(16) : Radius.zero, + bottomRight: !top && !left ? const Radius.circular(16) : Radius.zero, + ), + border: Border( + top: top ? BorderSide(color: color, width: 1.6) : BorderSide.none, + bottom: !top ? BorderSide(color: color, width: 1.6) : BorderSide.none, + left: left ? BorderSide(color: color, width: 1.6) : BorderSide.none, + right: !left ? BorderSide(color: color, width: 1.6) : BorderSide.none, + ), + ), + ), + ), + ); + } } diff --git a/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart index 2ad1da51..89dd9f54 100644 --- a/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart +++ b/mobile-app/lib/v2/screens/swap/token_picker_sheet.dart @@ -1,89 +1,246 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/components/token_icon.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( +typedef SwapTokenLoader = Future> Function({bool forceRefresh}); + +Future showTokenPickerSheet( + BuildContext context, { + required SwapTokenLoader loadTokens, + required SwapToken current, +}) { + return showGeneralDialog( context: context, - backgroundColor: Colors.transparent, - builder: (_) => _TokenPickerContent(tokens: tokens, current: current), + barrierDismissible: true, + barrierLabel: 'Select Token', + barrierColor: Colors.transparent, + pageBuilder: (_, _, _) => _TokenPickerContent(loadTokens: loadTokens, current: current), ); } -class _TokenPickerContent extends StatelessWidget { - final List tokens; +class _TokenPickerContent extends StatefulWidget { + final SwapTokenLoader loadTokens; final SwapToken current; - const _TokenPickerContent({required this.tokens, required this.current}); + const _TokenPickerContent({required this.loadTokens, required this.current}); + + @override + State<_TokenPickerContent> createState() => _TokenPickerContentState(); +} + +class _TokenPickerContentState extends State<_TokenPickerContent> { + final _scrollController = ScrollController(); + List _tokens = const []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadTokens(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _loadTokens({bool forceRefresh = false}) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final tokens = await widget.loadTokens(forceRefresh: forceRefresh); + if (!mounted) return; + setState(() { + _tokens = tokens; + _loading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Failed to load tokens'; + }); + } + } @override Widget build(BuildContext context) { final colors = context.colors; final text = context.themeText; + final size = MediaQuery.of(context).size; + final height = size.height; + final cardHeight = (height - 120).clamp(360.0, 506.0); - 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)), + return DefaultTextStyle( + style: const TextStyle(decoration: TextDecoration.none), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.5), + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + width: (size.width - 28).clamp(300.0, 362.0), + height: cardHeight, + margin: const EdgeInsets.fromLTRB(14, 0, 14, 20), + padding: const EdgeInsets.fromLTRB(24, 40, 24, 24), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + border: Border.all(color: const Color(0xFF3D3D3D)), + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Select Token', + style: text.smallTitle?.copyWith( + color: colors.textPrimary, + fontSize: 20, + decoration: TextDecoration.none, + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(Icons.close, color: colors.textPrimary, size: 20), + ), + ], + ), + const SizedBox(height: 24), + Expanded(child: _content(colors, text)), + ], + ), + ), + ), + ), ), + ), + ); + } + + Widget _content(AppColorsV2 colors, AppTextTheme text) { + if (_loading && _tokens.isEmpty) { + return Center(child: CircularProgressIndicator(color: colors.textPrimary, strokeWidth: 2)); + } + if (_error != null && _tokens.isEmpty) { + return Center( 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), + Text( + _error!, + style: text.smallParagraph?.copyWith(color: colors.textSecondary, decoration: TextDecoration.none), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () => _loadTokens(forceRefresh: true), + child: Text( + 'Retry', + style: text.paragraph?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, ), - ], + ), ), - const SizedBox(height: 24), - ...tokens.map((token) => _tokenRow(context, token, colors, text)), - const SizedBox(height: 16), ], ), - ), + ); + } + return Column( + children: [ + if (_error != null) ...[ + Row( + children: [ + Expanded( + child: Text( + _error!, + style: text.detail?.copyWith(color: colors.textSecondary, decoration: TextDecoration.none), + ), + ), + GestureDetector( + onTap: () => _loadTokens(forceRefresh: true), + child: Text( + 'Retry', + style: text.detail?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + ], + Expanded( + child: RefreshIndicator( + color: colors.textPrimary, + onRefresh: () => _loadTokens(forceRefresh: true), + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + radius: const Radius.circular(25), + thickness: 4, + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _tokens.length, + itemBuilder: (_, index) => _tokenRow(context, _tokens[index], colors, text), + ), + ), + ), + ), + ], ); } Widget _tokenRow(BuildContext context, SwapToken token, AppColorsV2 colors, AppTextTheme text) { - final selected = token == current; + final selected = token == widget.current; return GestureDetector( onTap: () => Navigator.pop(context, token), behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: selected ? Border.all(color: Colors.white.withValues(alpha: 0.44), width: 0.9) : null, + ), 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), + TokenIcon(token: token), + const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( token.symbol, - style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), + style: text.paragraph?.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + const SizedBox(height: 4), + Text( + token.network, + style: text.paragraph?.copyWith(color: colors.textSecondary, decoration: TextDecoration.none), ), - 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/theme/app_colors.dart b/mobile-app/lib/v2/theme/app_colors.dart index a81e5a57..54c77b27 100644 --- a/mobile-app/lib/v2/theme/app_colors.dart +++ b/mobile-app/lib/v2/theme/app_colors.dart @@ -40,6 +40,8 @@ class AppColorsV2 extends ThemeExtension { final Color skeletonBase; final Color skeletonHighlight; final Color toasterBorder; + final Color sheetBackground; + final Color borderSubtle; // Account tags final Color tagGuardian; @@ -51,6 +53,8 @@ class AppColorsV2 extends ThemeExtension { required this.backgroundAlt, required this.toasterBackground, required this.toasterBorder, + required this.sheetBackground, + required this.borderSubtle, required this.surface, required this.surfaceGlass, required this.surfaceCard, @@ -83,6 +87,8 @@ class AppColorsV2 extends ThemeExtension { backgroundAlt: const Color(0xFF1F1F1F), toasterBackground: const Color(0xFF191919), toasterBorder: const Color(0xFF3D3D3D), + sheetBackground: const Color(0xFF1A1A1A), + borderSubtle: const Color(0x70FFFFFF), surface: const Color(0xFF292929), surfaceGlass: const Color(0x1AFFFFFF), surfaceCard: const Color(0x0FFFFFFF), @@ -133,6 +139,8 @@ class AppColorsV2 extends ThemeExtension { Color? border, Color? toasterBackground, Color? toasterBorder, + Color? sheetBackground, + Color? borderSubtle, Color? buttonDisabled, Color? skeletonBase, Color? skeletonHighlight, @@ -145,6 +153,8 @@ class AppColorsV2 extends ThemeExtension { backgroundAlt: backgroundAlt ?? this.backgroundAlt, toasterBackground: toasterBackground ?? this.toasterBackground, toasterBorder: toasterBorder ?? this.toasterBorder, + sheetBackground: sheetBackground ?? this.sheetBackground, + borderSubtle: borderSubtle ?? this.borderSubtle, surface: surface ?? this.surface, surfaceGlass: surfaceGlass ?? this.surfaceGlass, surfaceCard: surfaceCard ?? this.surfaceCard, @@ -180,6 +190,8 @@ class AppColorsV2 extends ThemeExtension { backgroundAlt: Color.lerp(backgroundAlt, other.backgroundAlt, t) ?? backgroundAlt, toasterBackground: Color.lerp(toasterBackground, other.toasterBackground, t) ?? toasterBackground, toasterBorder: Color.lerp(toasterBorder, other.toasterBorder, t) ?? toasterBorder, + sheetBackground: Color.lerp(sheetBackground, other.sheetBackground, t) ?? sheetBackground, + borderSubtle: Color.lerp(borderSubtle, other.borderSubtle, t) ?? borderSubtle, surface: Color.lerp(surface, other.surface, t) ?? surface, surfaceGlass: Color.lerp(surfaceGlass, other.surfaceGlass, t) ?? surfaceGlass, surfaceCard: Color.lerp(surfaceCard, other.surfaceCard, t) ?? surfaceCard, diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 45e86d73..9e048be6 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -127,6 +127,7 @@ flutter: - assets/v2/auth_wrapper_bracket.png - assets/v2/glass_wide_clear.png - assets/v2/welcome_screen_bg_image.jpg + - assets/v2/glass_tiny_button.png fonts: diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index e66c7e0e..d8ce4f52 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -35,6 +35,9 @@ class AccountsService { } Future updateAccountName(Account account, String name) async { + if (name.isEmpty) { + throw Exception("Account name can't be empty"); + } final updatedAccount = account.copyWith(name: name); await _settingsService.updateAccount(updatedAccount); onAccountsChanged?.call(); diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 0a9997e6..cbeee4fb 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; enum SwapStatus { pending, depositing, processing, complete, failed, expired } @@ -8,8 +11,17 @@ class SwapToken { final String name; final String network; final int decimals; + final String? iconUrl; + final String? networkIconUrl; - const SwapToken({required this.symbol, required this.name, required this.network, this.decimals = 18}); + const SwapToken({ + required this.symbol, + required this.name, + required this.network, + this.decimals = 18, + this.iconUrl, + this.networkIconUrl, + }); @override bool operator ==(Object other) => other is SwapToken && symbol == other.symbol && network == other.network; @@ -75,7 +87,14 @@ class SwapService { static const _refundAddressKey = 'recent_refund_addresses'; static const _maxRefundAddresses = 50; + static const _intentsTokensUrl = 'https://1click.chaindefuser.com/v0/tokens'; + static const _coinGeckoTopUrl = + 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false'; + static const _tokensCacheTtl = Duration(minutes: 10); final _orders = {}; + List? _cachedFromTokens; + DateTime? _cachedFromTokensAt; + Map _liveUsdPriceBySymbol = {}; static const availableTokens = [ SwapToken(symbol: 'USDC', name: 'USD Coin', network: 'Ethereum'), @@ -88,41 +107,228 @@ class SwapService { static const _quToken = SwapToken(symbol: 'QUAN', name: 'Quantus', network: 'Quantus'); - List getFromTokens() => availableTokens.where((t) => t.symbol != 'QUAN').toList(); + Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { + final now = DateTime.now(); + if (!forceRefresh && + _cachedFromTokens != null && + _cachedFromTokensAt != null && + now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) { + return _cachedFromTokens!.take(limit).toList(); + } + + try { + final intentTokens = await _fetchNearIntentsTokens(); + if (intentTokens.isNotEmpty) { + final ranked = await _rankByCoinGecko(intentTokens); + _cachedFromTokens = ranked; + _cachedFromTokensAt = now; + _liveUsdPriceBySymbol = {for (final token in ranked) token.symbol.toUpperCase(): token.price}; + return ranked.take(limit).toList(); + } + } catch (_) {} + + final fallback = availableTokens.where((t) => t.symbol != 'QUAN').take(limit).toList(); + _cachedFromTokens = fallback; + _cachedFromTokensAt = now; + return fallback; + } SwapToken getQuToken() => _quToken; + static String formatTokenAmount(double amount, SwapToken token) { + if (amount == 0) return '0'; + final decimals = token.decimals.clamp(0, 12); + var s = amount.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); + if (s.endsWith('.')) s = s.substring(0, s.length - 1); + return s; + } + + static String formatTokenAmountHint(SwapToken token) { + if (token.decimals == 0) return '0'; + return '0.${'0' * token.decimals.clamp(1, 8)}'; + } + + String? getTokenIconUrl(SwapToken token) { + final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull; + if (cached?.iconUrl != null && cached!.iconUrl!.isNotEmpty) return cached.iconUrl; + return _fallbackTokenIconUrl(token.symbol); + } + + String? getNetworkIconUrl(SwapToken token) { + final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull; + if (cached?.networkIconUrl != null && cached!.networkIconUrl!.isNotEmpty) return cached.networkIconUrl; + return _networkIconUrl(token.network); + } + double getRate(SwapToken from) { - switch (from.symbol) { + final fromUsd = getUsdPrice(from); + final toUsd = getUsdPrice(_quToken); + if (fromUsd <= 0 || toUsd <= 0) return 1.0; + return fromUsd / toUsd; + } + + double getUsdPrice(SwapToken token) { + final livePrice = _liveUsdPriceBySymbol[token.symbol.toUpperCase()]; + if (livePrice != null && livePrice > 0) return livePrice; + switch (token.symbol.toUpperCase()) { case 'USDC': case 'USDT': - return 10.0; + return 1.0; case 'ETH': - return 25000.0; + return 2500.0; case 'BTC': return 60000.0; case 'SOL': - return 1500.0; - default: + return 150.0; + case 'QUAN': return 1.0; + default: + return 0.0; } } - double getUsdPrice(SwapToken token) { - switch (token.symbol) { + Future> _fetchNearIntentsTokens() async { + final response = await http.get(Uri.parse(_intentsTokensUrl)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Failed to fetch intents tokens'); + } + final data = jsonDecode(response.body); + if (data is! List) return const []; + + final bySymbol = {}; + for (final item in data) { + if (item is! Map) continue; + final symbolRaw = item['symbol']; + final blockchainRaw = item['blockchain']; + final decimalsRaw = item['decimals']; + final priceRaw = item['price']; + if (symbolRaw is! String || symbolRaw.isEmpty) continue; + if (blockchainRaw is! String || blockchainRaw.isEmpty) continue; + final symbol = symbolRaw.toUpperCase(); + if (symbol == 'QUAN') continue; + final price = (priceRaw as num?)?.toDouble() ?? 0; + if (price <= 0) continue; + final decimals = (decimalsRaw as num?)?.toInt() ?? 18; + final token = _IntentToken( + symbol: symbol, + network: blockchainRaw.toUpperCase(), + decimals: decimals, + price: price, + networkIconUrl: _networkIconUrl(blockchainRaw.toUpperCase()), + ); + final existing = bySymbol[symbol]; + if (existing == null || _networkPriority(token.network) < _networkPriority(existing.network)) { + bySymbol[symbol] = token; + } + } + return bySymbol.values.toList(); + } + + Future> _rankByCoinGecko(List<_IntentToken> tokens) async { + try { + final response = await http.get(Uri.parse(_coinGeckoTopUrl)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return _sortByPrice(tokens); + } + final payload = jsonDecode(response.body); + if (payload is! List) return _sortByPrice(tokens); + final rankBySymbol = {}; + final iconBySymbol = {}; + for (var i = 0; i < payload.length; i++) { + final item = payload[i]; + if (item is! Map) continue; + final symbol = (item['symbol'] as String?)?.toUpperCase(); + if (symbol == null || symbol.isEmpty || rankBySymbol.containsKey(symbol)) { + continue; + } + rankBySymbol[symbol] = i; + final icon = item['image'] as String?; + if (icon != null && icon.isNotEmpty) iconBySymbol[symbol] = icon; + } + final ranked = [ + for (final token in tokens) + token.copyWith(iconUrl: iconBySymbol[token.symbol] ?? token.iconUrl ?? _fallbackTokenIconUrl(token.symbol)), + ]; + ranked.sort((a, b) { + final ar = rankBySymbol[a.symbol] ?? 99999; + final br = rankBySymbol[b.symbol] ?? 99999; + if (ar != br) return ar.compareTo(br); + return b.price.compareTo(a.price); + }); + return ranked; + } catch (_) { + return _sortByPrice(tokens); + } + } + + List<_IntentToken> _sortByPrice(List<_IntentToken> tokens) { + final sorted = [...tokens]; + sorted.sort((a, b) => b.price.compareTo(a.price)); + return sorted; + } + + int _networkPriority(String network) { + switch (network) { + case 'ETH': + return 0; + case 'BTC': + return 1; + case 'SOL': + return 2; + case 'NEAR': + return 3; + case 'BASE': + return 4; + case 'ARB': + return 5; + default: + return 100; + } + } + + String? _fallbackTokenIconUrl(String symbol) { + switch (symbol) { case 'USDC': + return 'https://assets.coingecko.com/coins/images/6319/large/usdc.png'; case 'USDT': - return 1.0; + return 'https://assets.coingecko.com/coins/images/325/large/Tether.png'; case 'ETH': - return 2500.0; + case 'WETH': + return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; case 'BTC': - return 60000.0; + case 'WBTC': + case 'XBTC': + return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; case 'SOL': - return 150.0; - case 'QUAN': - return 0.10; + return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; + case 'NEAR': + case 'WNEAR': + return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; default: - return 0.0; + return null; + } + } + + String? _networkIconUrl(String network) { + switch (network) { + case 'ETH': + case 'BASE': + case 'ARB': + case 'OP': + case 'GNOSIS': + case 'AVAX': + case 'POL': + case 'MONAD': + case 'BSC': + return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'; + case 'BTC': + return 'https://assets.coingecko.com/coins/images/1/large/bitcoin.png'; + case 'SOL': + return 'https://assets.coingecko.com/coins/images/4128/large/solana.png'; + case 'NEAR': + return 'https://assets.coingecko.com/coins/images/10365/large/near.jpg'; + default: + return null; } } @@ -203,3 +409,26 @@ class SwapService { return prefs.getStringList(key) ?? []; } } + +class _IntentToken extends SwapToken { + final double price; + const _IntentToken({ + required super.symbol, + required super.network, + required super.decimals, + required this.price, + super.iconUrl, + super.networkIconUrl, + }) : super(name: symbol); + + _IntentToken copyWith({String? iconUrl, String? networkIconUrl}) { + return _IntentToken( + symbol: symbol, + network: network, + decimals: decimals, + price: price, + iconUrl: iconUrl ?? this.iconUrl, + networkIconUrl: networkIconUrl ?? this.networkIconUrl, + ); + } +}