diff --git a/lib/views/wallet_view.dart b/lib/views/wallet_view.dart index e217281..7b5648b 100644 --- a/lib/views/wallet_view.dart +++ b/lib/views/wallet_view.dart @@ -3,6 +3,8 @@ import 'package:dynamic_sdk_web3dart/dynamic_sdk_web3dart.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:web3dart/web3dart.dart'; +import 'package:my_app/widgets/send_erc20_widget.dart'; +import 'package:my_app/widgets/network_switch_widget.dart'; class WalletView extends StatefulWidget { final String walletId; @@ -243,12 +245,15 @@ class _WalletViewState extends State { 'Wallet not found. Go back and select a wallet.', style: TextStyle(color: Colors.red), ) - else + else ...[ _WalletHeader( wallet: wallet, balance: _balance, loadingBalance: _loadingBalance, ), + const SizedBox(height: 16), + NetworkSwitchWidget(wallet: wallet), + ], const SizedBox(height: 16), Card( child: Padding( @@ -351,6 +356,8 @@ class _WalletViewState extends State { ), ), const SizedBox(height: 16), + if (wallet != null) SendErc20Widget(wallet: wallet), + const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton.icon( diff --git a/lib/widgets/network_switch_widget.dart b/lib/widgets/network_switch_widget.dart new file mode 100644 index 0000000..44deccc --- /dev/null +++ b/lib/widgets/network_switch_widget.dart @@ -0,0 +1,149 @@ +import 'package:dynamic_sdk/dynamic_sdk.dart'; +import 'package:flutter/material.dart'; + +class NetworkSwitchWidget extends StatefulWidget { + final BaseWallet wallet; + const NetworkSwitchWidget({super.key, required this.wallet}); + + @override + State createState() => _NetworkSwitchWidgetState(); +} + +class _NetworkSwitchWidgetState extends State { + List _availableNetworks = const []; + GenericNetwork? _selectedNetwork; + bool _loading = false; + bool _switching = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadNetworksAndCurrent(); + } + + Future _loadNetworksAndCurrent() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final networks = DynamicSDK.instance.networks.evm; + final current = await DynamicSDK.instance.wallets.getNetwork( + wallet: widget.wallet, + ); + final currentChainId = current.intValue(); + + GenericNetwork? selected; + if (currentChainId != null) { + for (final n in networks) { + if (n.chainId == currentChainId) { + selected = n; + break; + } + } + } + + setState(() { + _availableNetworks = networks; + _selectedNetwork = + selected ?? (networks.isNotEmpty ? networks.first : null); + }); + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + if (!mounted) return; + setState(() { + _loading = false; + }); + } + } + + Future _switchNetwork(GenericNetwork target) async { + setState(() { + _switching = true; + _error = null; + }); + try { + await DynamicSDK.instance.wallets.switchNetwork( + wallet: widget.wallet, + network: Network(target.chainId), + ); + if (!mounted) return; + setState(() { + _selectedNetwork = target; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Switched to ${target.name}'))); + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + if (!mounted) return; + setState(() { + _switching = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Network', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + if (_loading) + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (_availableNetworks.isEmpty) + const Text( + 'No networks available', + style: TextStyle(fontSize: 12, color: Colors.grey), + ) + else + DropdownButtonFormField( + value: _selectedNetwork, + items: _availableNetworks + .map( + (n) => DropdownMenuItem( + value: n, + child: Text(n.name), + ), + ) + .toList(), + onChanged: _switching + ? null + : (val) { + if (val != null) { + _switchNetwork(val); + } + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Select network', + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: const TextStyle(color: Colors.red)), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/send_erc20_widget.dart b/lib/widgets/send_erc20_widget.dart new file mode 100644 index 0000000..2a758f7 --- /dev/null +++ b/lib/widgets/send_erc20_widget.dart @@ -0,0 +1,332 @@ +import 'package:dynamic_sdk/dynamic_sdk.dart'; +import 'package:dynamic_sdk_web3dart/dynamic_sdk_web3dart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web3dart/web3dart.dart'; + +class SendErc20Widget extends StatefulWidget { + final BaseWallet wallet; + const SendErc20Widget({super.key, required this.wallet}); + + @override + State createState() => _SendErc20WidgetState(); +} + +class _SendErc20WidgetState extends State { + final TextEditingController _erc20AddressController = TextEditingController(); + final TextEditingController _recipientController = TextEditingController(); + final TextEditingController _amountTokenController = TextEditingController(); + + bool _busy = false; + String? _error; + String? _tokenBalanceText; + String? _tokenSymbol; + int? _tokenDecimals; + String? _lastTxHash; + + @override + void dispose() { + _erc20AddressController.dispose(); + _recipientController.dispose(); + _amountTokenController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Send ERC-20', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + TextField( + controller: _erc20AddressController, + decoration: const InputDecoration( + labelText: 'ERC-20 Token Address (0x...)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _busy ? null : _loadTokenInfo, + child: const Text('Load Token'), + ), + ), + if (_tokenBalanceText != null) ...[ + const SizedBox(height: 8), + Text( + 'Balance: $_tokenBalanceText${_tokenSymbol != null ? ' $_tokenSymbol' : ''}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: const TextStyle(color: Colors.red)), + ], + if (_tokenDecimals != null) ...[ + const SizedBox(height: 12), + TextField( + controller: _recipientController, + decoration: const InputDecoration( + labelText: 'Recipient (0x...)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _amountTokenController, + decoration: InputDecoration( + labelText: + 'Amount${_tokenSymbol != null ? ' (${_tokenSymbol})' : ''}', + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _busy ? null : _sendToken, + child: const Text('Send Token'), + ), + ), + if (_lastTxHash != null) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SelectableText( + 'Tx Hash: $_lastTxHash', + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + IconButton( + tooltip: 'Copy tx hash', + icon: const Icon(Icons.copy, size: 18), + onPressed: () async { + if (_lastTxHash == null) return; + await Clipboard.setData( + ClipboardData(text: _lastTxHash!), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Transaction hash copied'), + ), + ); + }, + ), + ], + ), + ], + ], + ], + ), + ), + ); + } + + Future _loadTokenInfo() async { + final address = _erc20AddressController.text.trim(); + if (address.isEmpty) { + setState(() => _error = 'Token address is required'); + return; + } + setState(() { + _busy = true; + _error = null; + _tokenBalanceText = null; + _tokenSymbol = null; + _tokenDecimals = null; + }); + try { + final network = await DynamicSDK.instance.wallets.getNetwork( + wallet: widget.wallet, + ); + + final client = DynamicSDK.instance.web3dart.createPublicClient( + chainId: network.intValue()!, + ); + + final contract = DeployedContract( + ContractAbi.fromJson(_erc20Abi, ''), + EthereumAddress.fromHex(address), + ); + + final balanceOfFn = contract.function('balanceOf'); + final decimalsFn = contract.function('decimals'); + final symbolFn = contract.function('symbol'); + + final balanceRes = await client.call( + contract: contract, + function: balanceOfFn, + params: [EthereumAddress.fromHex(widget.wallet.address)], + ); + final raw = balanceRes.first as BigInt; + + int decimals = 18; + try { + final decRes = await client.call( + contract: contract, + function: decimalsFn, + params: const [], + ); + final decDyn = decRes.first; + if (decDyn is BigInt) { + decimals = decDyn.toInt(); + } else if (decDyn is int) { + decimals = decDyn; + } + } catch (_) {} + + String? symbol; + try { + final symRes = await client.call( + contract: contract, + function: symbolFn, + params: const [], + ); + final s = symRes.first; + if (s is String) symbol = s; + } catch (_) {} + + setState(() { + _tokenDecimals = decimals; + _tokenSymbol = symbol; + _tokenBalanceText = _formatTokenAmount(raw, decimals); + }); + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + if (!mounted) return; + setState(() { + _busy = false; + }); + } + } + + // Removed placeholder send function since real send is implemented + + Future _sendToken() async { + final tokenAddress = _erc20AddressController.text.trim(); + final recipient = _recipientController.text.trim(); + final amountStr = _amountTokenController.text.trim(); + if (tokenAddress.isEmpty) { + setState(() => _error = 'Token address is required'); + return; + } + if (_tokenDecimals == null) { + setState(() => _error = 'Load token first to get its decimals'); + return; + } + if (recipient.isEmpty) { + setState(() => _error = 'Recipient is required'); + return; + } + if (amountStr.isEmpty) { + setState(() => _error = 'Amount is required'); + return; + } + + setState(() { + _busy = true; + _error = null; + _lastTxHash = null; + }); + try { + final amountInUnits = _parseUnits(amountStr, _tokenDecimals!); + + const List> erc20Abi = [ + { + "inputs": [ + {"internalType": "address", "name": "to", "type": "address"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [ + {"internalType": "bool", "name": "", "type": "bool"}, + ], + "stateMutability": "nonpayable", + "type": "function", + }, + ]; + + final txHash = await DynamicSDK.instance.web3dart.writeContract( + wallet: widget.wallet, + input: WriteContractInput( + abi: erc20Abi, + args: [recipient, amountInUnits], + address: tokenAddress, + functionName: 'transfer', + value: BigInt.zero, + ), + ); + + setState(() { + _lastTxHash = txHash; + }); + + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Transaction submitted'))); + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + if (!mounted) return; + setState(() { + _busy = false; + }); + } + } + + BigInt _parseUnits(String value, int decimals) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return BigInt.zero; + final parts = trimmed.split('.'); + final integerPart = parts[0].replaceAll(RegExp(r'[^0-9]'), ''); + String fractionPart = parts.length > 1 + ? parts[1].replaceAll(RegExp(r'[^0-9]'), '') + : ''; + if (fractionPart.length > decimals) { + fractionPart = fractionPart.substring(0, decimals); + } + final paddedFraction = fractionPart.padRight(decimals, '0'); + final whole = integerPart.isEmpty ? '0' : integerPart; + return BigInt.parse(whole) * BigInt.from(10).pow(decimals) + + (paddedFraction.isEmpty ? BigInt.zero : BigInt.parse(paddedFraction)); + } + + String _formatTokenAmount(BigInt raw, int decimals) { + if (decimals <= 0) return raw.toString(); + final divisor = BigInt.from(10).pow(decimals); + final integer = raw ~/ divisor; + final fraction = (raw % divisor).toString().padLeft(decimals, '0'); + final trimmedFraction = fraction.replaceFirst(RegExp(r'0+$'), ''); + final displayFraction = trimmedFraction.isEmpty + ? '0' + : trimmedFraction.substring(0, trimmedFraction.length.clamp(0, 6)); + return '$integer.$displayFraction'; + } + + static const String _erc20Abi = + '[\n {"constant":true,"inputs":[{"name":"","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},\n {"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},\n {"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"}\n ]'; +} diff --git a/pubspec.lock b/pubspec.lock index 079ae21..aed2ede 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,18 +77,18 @@ packages: dependency: "direct main" description: name: dynamic_sdk - sha256: e89366edf7f71d86508cc655ec366ba199a6c9aac97fa3234ba5287259d89ceb + sha256: a402e0deeb8c94924f6a05fb3c07268ba67d53ef76c5d1d2c4f0a1ffbaceee7d url: "https://pub.dev" source: hosted - version: "0.0.1-alpha.4" + version: "1.0.2" dynamic_sdk_web3dart: dependency: "direct main" description: name: dynamic_sdk_web3dart - sha256: c3f0beee856e56251e560cb977b73d9a4b393514b0cf0a95da922bea70470dd4 + sha256: d84a4145b90653e8a47bc3cf2e58e7412456fc89f473753af33da2318d40dda1 url: "https://pub.dev" source: hosted - version: "0.0.1-alpha.4" + version: "1.0.2" eip1559: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e9b1e2..f64930d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,8 +34,8 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - dynamic_sdk: ^0.0.1-alpha.4 - dynamic_sdk_web3dart: ^0.0.1-alpha.4 + dynamic_sdk: ^1.0.2 + dynamic_sdk_web3dart: ^1.0.2 go_router: ^16.2.0 dev_dependencies: