diff --git a/.github/workflows/format_analyze.yml b/.github/workflows/format_analyze.yml index 4e86c8ceb..71d34d351 100644 --- a/.github/workflows/format_analyze.yml +++ b/.github/workflows/format_analyze.yml @@ -21,6 +21,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: "3.27.2" channel: 'stable' - name: Get dependencies diff --git a/app/assets/usdc-icon.png b/app/assets/usdc-icon.png new file mode 100644 index 000000000..53ffef5c2 Binary files /dev/null and b/app/assets/usdc-icon.png differ diff --git a/app/lib/apps/market/market.dart b/app/lib/apps/market/market.dart new file mode 100644 index 000000000..4dfd5019a --- /dev/null +++ b/app/lib/apps/market/market.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; +import 'package:threebotlogin/app.dart'; +import 'package:threebotlogin/apps/farmers/farmers_user_data.dart'; +import 'package:threebotlogin/events/events.dart'; +import 'package:threebotlogin/events/go_home_event.dart'; +import 'package:threebotlogin/screens/market/market_screen.dart'; + +class Market implements App { + static final Market _singleton = Market._internal(); + static const Widget _daoWidget = MarketPage(); + + factory Market() { + return _singleton; + } + + Market._internal(); + + @override + Future widget() async { + return _daoWidget; + } + + @override + void clearData() { + clearAllData(); + } + + @override + bool emailVerificationRequired() { + return false; + } + + @override + bool pinRequired() { + return true; + } + + @override + void back() { + Events().emit(GoHomeEvent()); + } +} diff --git a/app/lib/jrouter.dart b/app/lib/jrouter.dart index 4bd646daa..36e5e2f08 100644 --- a/app/lib/jrouter.dart +++ b/app/lib/jrouter.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:threebotlogin/app.dart'; import 'package:threebotlogin/apps/council/council.dart'; import 'package:threebotlogin/apps/dao/dao.dart'; +import 'package:threebotlogin/apps/market/market.dart'; import 'package:threebotlogin/apps/wallet/wallet.dart'; import 'package:threebotlogin/screens/identity_verification_screen.dart'; import 'package:threebotlogin/screens/preference_screen.dart'; @@ -71,6 +72,15 @@ class JRouter { view: const IdentityVerificationScreen(), ), app: null), + AppInfo( + route: Route( + path: '/market', + name: 'Market', + icon: Icons.show_chart_sharp, + view: await Market().widget(), + ), + app: Market(), + ), AppInfo( route: Route( path: '/settings', diff --git a/app/lib/models/market_data.dart b/app/lib/models/market_data.dart new file mode 100644 index 000000000..c82fdf45c --- /dev/null +++ b/app/lib/models/market_data.dart @@ -0,0 +1,65 @@ +class TftMarketData { + final double lastPrice; + final double lastUsdPrice; + final double change24h; + final double high24h; + final double low24h; + final double volume24h; + + TftMarketData({ + required this.lastPrice, + required this.lastUsdPrice, + required this.change24h, + required this.high24h, + required this.low24h, + required this.volume24h, + }); + + factory TftMarketData.fromTrades(List trades) { + if (trades.isEmpty) return TftMarketData.empty(); + + final latestTrade = trades.first; + final double lastUsdcPrice = double.parse(latestTrade['base_amount']) / + double.parse(latestTrade['counter_amount']); + final double lastPrice = 1 / lastUsdcPrice; + + double high24h = lastUsdcPrice; + double low24h = lastUsdcPrice; + double volume24h = 0; + + final oldestTrade = trades.last; + final double oldestUsdcPrice = double.parse(oldestTrade['base_amount']) / + double.parse(oldestTrade['counter_amount']); + + for (var trade in trades) { + double usdcPrice = double.parse(trade['base_amount']) / + double.parse(trade['counter_amount']); + high24h = usdcPrice > high24h ? usdcPrice : high24h; + low24h = usdcPrice < low24h ? usdcPrice : low24h; + volume24h += double.parse(trade['counter_amount']); + } + + final double change24h = + ((lastUsdcPrice - oldestUsdcPrice) / oldestUsdcPrice) * 100; + + return TftMarketData( + lastPrice: 1 / lastPrice, + lastUsdPrice: lastUsdcPrice, + change24h: change24h, + high24h: high24h, + low24h: low24h, + volume24h: volume24h / 1000, + ); + } + + factory TftMarketData.empty() { + return TftMarketData( + lastPrice: 0, + lastUsdPrice: 0, + change24h: 0, + high24h: 0, + low24h: 0, + volume24h: 0, + ); + } +} diff --git a/app/lib/models/offer.dart b/app/lib/models/offer.dart new file mode 100644 index 000000000..d246c93ae --- /dev/null +++ b/app/lib/models/offer.dart @@ -0,0 +1,80 @@ +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import 'package:threebotlogin/services/stellar_service.dart'; + +class Offer { + final String id; + final String seller; + final String sellingAsset; + final String buyingAsset; + final String amount; + final String price; + final String lastModifiedTime; + + Offer({ + required this.id, + required this.seller, + required this.sellingAsset, + required this.buyingAsset, + required this.amount, + required this.price, + required this.lastModifiedTime, + }); + + factory Offer.fromOfferResponse(OfferResponse response) { + return Offer( + id: response.id, + seller: response.seller, + sellingAsset: getAssetName(response.selling), + buyingAsset: getAssetName(response.buying), + amount: response.amount, + price: response.price, + lastModifiedTime: response.lastModifiedTime, + ); + } + + factory Offer.fromTradeResponse(TradeResponse response) { + final bool userIsSeller = + response.baseAccount == response.links.base.href.split('/').last; + final String sellingAsset = userIsSeller + ? _getAssetNameFromTrade(response.baseAssetType, response.baseAssetCode, + response.baseAssetIssuer) + : _getAssetNameFromTrade(response.counterAssetType, + response.counterAssetCode, response.counterAssetIssuer); + + final String buyingAsset = userIsSeller + ? _getAssetNameFromTrade(response.counterAssetType, + response.counterAssetCode, response.counterAssetIssuer) + : _getAssetNameFromTrade(response.baseAssetType, response.baseAssetCode, + response.baseAssetIssuer); + final String amount = + userIsSeller ? response.baseAmount : response.counterAmount; + + String priceStr; + try { + Price priceObj = response.price; + priceStr = (priceObj.numerator! / priceObj.denominator!).toString(); + } catch (e) { + priceStr = '0'; + } + + return Offer( + id: response.id, + seller: userIsSeller ? response.baseAccount! : response.counterAccount!, + sellingAsset: sellingAsset, + buyingAsset: buyingAsset, + amount: amount, + price: priceStr, + lastModifiedTime: response.ledgerCloseTime, + ); + } + + static String _getAssetNameFromTrade( + String type, String? code, String? issuer) { + if (type == 'native') { + return 'XLM'; + } else if (code != null && issuer != null) { + return code; + } + return 'Unknown Asset'; + } +} diff --git a/app/lib/models/order_book.dart b/app/lib/models/order_book.dart new file mode 100644 index 000000000..cc7228b00 --- /dev/null +++ b/app/lib/models/order_book.dart @@ -0,0 +1,87 @@ +class OrderBook { + final String base; + final String counter; + final List bids; + final List asks; + + OrderBook({ + required this.base, + required this.counter, + required this.bids, + required this.asks, + }); + + factory OrderBook.fromJson(Map json) { + return OrderBook( + base: json['base'], + counter: json['counter'], + bids: (json['bids'] as List) + .map((e) => OrderOffer.fromJson(e)) + .toList(), + asks: (json['asks'] as List) + .map((e) => OrderOffer.fromJson(e)) + .toList(), + ); + } + + Map toJson() { + return { + 'base': base, + 'counter': counter, + 'bids': bids.map((e) => e.toJson()).toList(), + 'asks': asks.map((e) => e.toJson()).toList(), + }; + } +} + +class OrderOffer { + final String amount; + final String price; + final PriceR priceR; + + OrderOffer({ + required this.amount, + required this.price, + required this.priceR, + }); + + factory OrderOffer.fromJson(Map json) { + return OrderOffer( + amount: json['amount'], + price: json['price'], + priceR: PriceR.fromJson(json['price_r']), + ); + } + + Map toJson() { + return { + 'amount': amount, + 'price': price, + 'price_r': priceR.toJson(), + }; + } +} + +class PriceR { + final int numerator; + final int denominator; + + PriceR({ + required this.numerator, + required this.denominator, + }); + + factory PriceR.fromJson(Map json) { + return PriceR( + numerator: json['n'], + denominator: json['d'], + ); + } + + Map toJson() { + return { + 'n': numerator, + 'd': denominator, + }; + } +} diff --git a/app/lib/models/wallet.dart b/app/lib/models/wallet.dart index 28eaaec3c..fdc452764 100644 --- a/app/lib/models/wallet.dart +++ b/app/lib/models/wallet.dart @@ -11,7 +11,7 @@ class Wallet { required this.name, required this.stellarSecret, required this.stellarAddress, - required this.stellarBalance, + required this.stellarBalances, required this.tfchainSecret, required this.tfchainAddress, required this.tfchainBalance, @@ -23,7 +23,7 @@ class Wallet { final String stellarAddress; final String tfchainSecret; final String tfchainAddress; - String stellarBalance; + final Map stellarBalances; String tfchainBalance; final WalletType type; VerificationState verificationStatus; diff --git a/app/lib/providers/orders_notifier.dart b/app/lib/providers/orders_notifier.dart new file mode 100644 index 000000000..76de17d09 --- /dev/null +++ b/app/lib/providers/orders_notifier.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class OrderNotifier { + static final ValueNotifier orderUpdated = ValueNotifier(false); + + static void emitUpdate() { + orderUpdated.value = !orderUpdated.value; + } +} diff --git a/app/lib/providers/wallets_provider.dart b/app/lib/providers/wallets_provider.dart index 0abd41604..7820b0002 100644 --- a/app/lib/providers/wallets_provider.dart +++ b/app/lib/providers/wallets_provider.dart @@ -92,11 +92,11 @@ class WalletsNotifier extends StateNotifier> { final tfchainBalance = balance.toString() == '0.0' ? '0' : balance.toString(); final stellarBalance = - await StellarService.getBalance(wallet.stellarSecret); + await StellarService.getTFTBalance(wallet.stellarSecret); if (tfchainBalance != wallet.tfchainBalance || - stellarBalance != wallet.stellarBalance) { - wallet.stellarBalance = stellarBalance; + stellarBalance != wallet.stellarBalances['TFT']) { + wallet.stellarBalances['TFT'] = stellarBalance; wallet.tfchainBalance = tfchainBalance; } } diff --git a/app/lib/screens/council_screen.dart b/app/lib/screens/council_screen.dart index d960bf9c8..9989511ae 100644 --- a/app/lib/screens/council_screen.dart +++ b/app/lib/screens/council_screen.dart @@ -145,7 +145,7 @@ class _CouncilScreenState extends State { child: Text( style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: errorMessage == null ? Theme.of(context).colorScheme.primary diff --git a/app/lib/screens/farm_details.dart b/app/lib/screens/farm_details.dart index acbaae752..48612cb5d 100644 --- a/app/lib/screens/farm_details.dart +++ b/app/lib/screens/farm_details.dart @@ -344,10 +344,10 @@ class _FarmDetailsState extends State { wallets: widget.wallets .where((w) => double.tryParse(w - .stellarBalance) != + .stellarBalances['TFT']!) != null && double.parse(w - .stellarBalance) >= + .stellarBalances['TFT']!) >= 0) .toList(), onSelectToAddress: diff --git a/app/lib/screens/farm_screen.dart b/app/lib/screens/farm_screen.dart index 0484400e2..3c84a552a 100644 --- a/app/lib/screens/farm_screen.dart +++ b/app/lib/screens/farm_screen.dart @@ -232,7 +232,7 @@ class _FarmScreenState extends ConsumerState onPressed: _openAddFarmOverlay, child: Text( 'Create New Farm', - style: Theme.of(context).textTheme.bodyLarge!.copyWith( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold, ), diff --git a/app/lib/screens/market/buy_tft.dart b/app/lib/screens/market/buy_tft.dart new file mode 100644 index 000000000..5e2a555b1 --- /dev/null +++ b/app/lib/screens/market/buy_tft.dart @@ -0,0 +1,715 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:stellar_client/models/exceptions.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/helpers/transaction_helpers.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/providers/orders_notifier.dart'; +import 'package:threebotlogin/screens/market/order.dart'; +import 'package:threebotlogin/services/stellar_service.dart' as Stellar; +import 'package:threebotlogin/widgets/custom_dialog.dart'; + +class BuyTFTWidget extends StatefulWidget { + final Wallet wallet; + final Offer? offer; + final bool edit; + const BuyTFTWidget( + {super.key, required this.wallet, this.offer, required this.edit}); + + @override + State createState() => _BuyTFTWidgetState(); +} + +class _BuyTFTWidgetState extends State { + late TextEditingController amountController; + late TextEditingController priceController; + final totalAmountController = TextEditingController(); + final FocusNode textFieldFocusNode = FocusNode(); + String? amountError; + String? priceError; + bool loading = false; + double? currentMarketPrice; + List percentages = [25, 50, 75, 100]; + bool loadingBalance = true; + String? availableUSDC; + + @override + void initState() { + super.initState(); + amountController = TextEditingController( + text: widget.edit ? widget.offer?.amount.toString() ?? '' : ''); + priceController = TextEditingController( + text: widget.edit ? widget.offer?.price.toString() ?? '' : ''); + amountController.addListener(_calculateTotal); + priceController.addListener(_calculateTotal); + if (widget.edit) _calculateTotal(); + if (!widget.edit) _fetchCurrentMarketPrice(); + _getAvailableUSDC(); + } + + @override + void dispose() { + textFieldFocusNode.dispose(); + amountController.dispose(); + priceController.dispose(); + totalAmountController.dispose(); + amountError = null; + priceError = null; + amountController.removeListener(_calculateTotal); + priceController.removeListener(_calculateTotal); + super.dispose(); + } + + _getAvailableUSDC() async { + setState(() { + loadingBalance = true; + }); + + try { + final available = await Stellar.getAvailableUSDCBalance( + widget.wallet.stellarSecret, widget.offer); + + if (mounted) { + setState(() { + availableUSDC = available; + loadingBalance = false; + }); + } + } catch (e) { + logger.e('Error fetching USDC balance: $e'); + if (mounted) { + setState(() { + availableUSDC = '0'; + loadingBalance = false; + }); + } + } + } + + Future _fetchCurrentMarketPrice() async { + try { + final marketData = await Stellar.fetchTftMarketData(); + if (marketData != null && marketData.lastUsdPrice > 0) { + setState(() { + currentMarketPrice = 1 / marketData.lastUsdPrice; + + if (!widget.edit && priceController.text.isEmpty) { + priceController.text = currentMarketPrice!.toStringAsFixed(7); + _calculateTotal(); + } + }); + } else { + setState(() { + priceController.text = ''; + }); + } + } catch (e) { + logger.e('Error fetching market price: $e'); + setState(() { + priceController.text = ''; + }); + } + } + + bool _validateAmount() { + final amount = amountController.text.trim(); + amountError = null; + + if (loadingBalance) { + setState(() { + amountError = 'Please wait for balance to load'; + }); + return false; + } + + if (amount.isEmpty) { + setState(() { + amountError = "Amount can't be empty"; + }); + return false; + } + final balance = roundAmount(availableUSDC ?? '0'); + + if (balance - Decimal.parse(amount) <= Decimal.zero) { + setState(() { + amountError = 'Balance is not enough'; + }); + return false; + } + + if (Decimal.parse(amount) > Decimal.parse(availableUSDC ?? '0')) { + setState(() { + amountError = 'Not enough balance'; + }); + return false; + } + return true; + } + + bool _validatePrice() { + final price = priceController.text.trim(); + priceError = null; + + if (price.isEmpty) { + setState(() { + priceError = "Price can't be empty"; + }); + return false; + } + if (Decimal.parse(price) <= Decimal.zero) { + setState(() { + priceError = 'Price should be positive'; + }); + return false; + } + + return true; + } + + calculateAmount(int percentage) { + final amount = Decimal.parse(availableUSDC ?? '0') * + (Decimal.fromInt(percentage).shift(-2)); + amountController.text = roundAmount(amount.toString()).toString(); + _calculateTotal(); + } + + void _calculateTotal() { + final amountText = amountController.text.trim(); + final priceText = priceController.text.trim(); + + if (amountText.isNotEmpty && priceText.isNotEmpty) { + try { + final amount = Decimal.parse(amountText); + final price = Decimal.parse(priceText); + final total = amount * price; + totalAmountController.text = roundAmount(total.toString()).toString(); + } catch (e) { + totalAmountController.text = ''; + } + } else { + totalAmountController.text = ''; + } + } + + bool _checkForChanges() { + final boolean = (amountController.text != widget.offer!.amount) || + (priceController.text != widget.offer!.price); + return boolean; + } + + _createOrder() async { + setState(() { + loading = true; + }); + try { + final success = await Stellar.createOrder(widget.wallet.stellarSecret, + 'USDC', 'TFT', amountController.text, priceController.text); + if (success) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.check, + title: 'Success!', + description: 'Your order was created successfully.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => OrderWidget( + selectedWallet: widget.wallet, + ), + )); + OrderNotifier.emitUpdate(); + }, + ) + ]), + ); + } else { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Failed!', + type: DialogType.Error, + description: 'Failed to create your order.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + } + } on StellarBalanceException catch (_) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Balance Error', + type: DialogType.Error, + description: + 'You need to fund your account with some XLMs to create your order', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } catch (e) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Error', + type: DialogType.Error, + description: 'Error creating your order: ${e.toString()}', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } finally { + setState(() { + loading = false; + }); + } + } + + _updateOrder() async { + final bool shouldCancel = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext customContext) => CustomDialog( + type: DialogType.Warning, + image: Icons.warning, + title: 'Update Order', + description: 'Are you sure you want to update this order?', + actions: [ + TextButton( + child: const Text('No'), + onPressed: () { + Navigator.pop(customContext, false); + }, + ), + TextButton( + child: const Text('Yes'), + onPressed: () { + Navigator.pop(customContext, true); + }, + ), + ], + ), + ) ?? + false; + + if (!shouldCancel) return; + setState(() { + loading = true; + }); + try { + final success = await Stellar.updateOrder(widget.wallet.stellarSecret, + amountController.text, priceController.text, widget.offer!.id); + if (success) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.check, + title: 'Success!', + description: 'Your order was updated successfully.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + Navigator.pop(context); + OrderNotifier.emitUpdate(); + }, + ) + ]), + ); + } else { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Failed!', + type: DialogType.Error, + description: 'Failed to update your order.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + } + } on StellarBalanceException catch (_) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Balance Error', + type: DialogType.Error, + description: + 'You need to fund your account with some XLMs to update your order', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } catch (e) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Error', + type: DialogType.Error, + description: 'Error updating your order: ${e.toString()}', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } finally { + setState(() { + loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: widget.edit ? const Text('Edit Order') : const Text('Buy')), + body: KeyboardVisibilityBuilder(builder: (context, isKeyboardVisible) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + FocusScope.of(context).unfocus(); + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 30), + ListTile( + title: TextField( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + focusNode: textFieldFocusNode, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, signed: false), + controller: amountController, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Amount', + errorText: amountError, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/usdc-icon.png', + color: Theme.of(context).colorScheme.onSurface, + width: 20, + height: 20, + ), + const SizedBox( + width: 5, + ), + Text( + 'USDC', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer), + ), + const SizedBox(width: 10), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Align( + alignment: Alignment.centerRight, + child: loadingBalance + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: + Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + 'Loading balance...', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ) + : Text( + 'Available: $availableUSDC USDC', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: percentages + .map( + (percentage) => OutlinedButton( + style: OutlinedButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(5)))), + onPressed: loadingBalance + ? null + : () => calculateAmount(percentage), + child: Text('$percentage%'), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 100), + ListTile( + title: TextField( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, signed: false), + controller: priceController, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Price', + errorText: priceError, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/tf_chain.png', + color: Theme.of(context).colorScheme.onSurface, + width: 20, + height: 20, + ), + const SizedBox( + width: 5, + ), + Text('TFT', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer)), + const SizedBox(width: 10), + ], + ), + ), + ), + ), + const SizedBox(height: 20), + ListTile( + title: TextField( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + controller: totalAmountController, + readOnly: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Total amount', + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/tf_chain.png', + color: Theme.of(context).colorScheme.onSurface, + width: 20, + height: 20, + ), + const SizedBox(width: 5), + Text( + 'TFT', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer), + ), + const SizedBox( + width: 10, + ), + ], + ), + ), + ), + ), + if (amountError != null && priceError != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15.0, vertical: 5.0), + child: Text( + 'You are selling ${amountController.text} USDC for ${totalAmountController.text} TFT. ', + style: + Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 10), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: loading || loadingBalance + ? null + : () async { + if (_validateAmount() && _validatePrice()) { + if (widget.edit && _checkForChanges()) { + _updateOrder(); + } else { + _createOrder(); + } + } else { + setState(() {}); + } + }, + style: ElevatedButton.styleFrom(), + child: loading + ? const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3), + ), + ], + ) + : Text( + widget.edit ? 'Edit order' : 'Buy TFT', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 10), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHigh, + ), + child: Text( + 'Cancel', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ), + )); + })); + } +} diff --git a/app/lib/screens/market/market_screen.dart b/app/lib/screens/market/market_screen.dart new file mode 100644 index 000000000..ebc74c20e --- /dev/null +++ b/app/lib/screens/market/market_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:threebotlogin/widgets/layout_drawer.dart'; +import 'package:threebotlogin/screens/market/order_book.dart'; +import 'package:threebotlogin/screens/market/overview.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class MarketPage extends ConsumerStatefulWidget { + const MarketPage({super.key}); + + @override + ConsumerState createState() => _MarketPageState(); +} + +class _MarketPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutDrawer( + titleText: 'Market', + content: DefaultTabController( + length: 2, + child: Column(children: [ + PreferredSize( + preferredSize: const Size.fromHeight(10.0), + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: TabBar( + controller: _tabController, + labelColor: Theme.of(context).colorScheme.primary, + indicatorColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of(context).colorScheme.onSurface, + dividerColor: Theme.of(context).scaffoldBackgroundColor, + labelStyle: Theme.of(context).textTheme.titleMedium, + unselectedLabelStyle: Theme.of(context).textTheme.titleMedium, + tabs: const [ + Tab(text: 'Overview'), + Tab(text: 'OrderBook'), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + OverviewWidget(), + OrderbookWidget(), + ], + ), + ) + ]), + ), + ); + } +} diff --git a/app/lib/screens/market/order.dart b/app/lib/screens/market/order.dart new file mode 100644 index 000000000..279d5ecea --- /dev/null +++ b/app/lib/screens/market/order.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:threebotlogin/models/wallet.dart' as Wallet; +import 'package:threebotlogin/services/stellar_service.dart'; +import 'package:threebotlogin/providers/orders_notifier.dart'; +import 'package:threebotlogin/widgets/market/orders_widget.dart'; + +class OrderWidget extends StatefulWidget { + final Wallet.Wallet selectedWallet; + const OrderWidget({super.key, required this.selectedWallet}); + + @override + State createState() => _OrderWidgetState(); +} + +class _OrderWidgetState extends State + with SingleTickerProviderStateMixin { + bool loading = true; + bool failed = false; + late final TabController _tabController; + final List activeOrders = []; + final List previousOrders = []; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_handleTabChange); + + loadOrders(); + OrderNotifier.orderUpdated.addListener(() { + loadOrders(); + }); + } + + void _handleTabChange() { + if (_tabController.indexIsChanging) { + logger.i('Tab changed to index: ${_tabController.index}'); + setState(() {}); + } + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future loadOrders() async { + _setLoadingState(); + + try { + final connectivityResult = await (Connectivity().checkConnectivity()); + + if (connectivityResult.contains(ConnectivityResult.none)) { + _handleFailure( + 'No internet connection. Please check your network.', + ); + return; + } + + Asset sellingAsset = + AssetTypeCreditAlphaNum4(usdcAssetCode, usdcAssetIssuer); + Asset buyingAsset = + AssetTypeCreditAlphaNum4(tftAssetCode, tftAssetIssuer); + + final currentOrders = + await getActiveOrders(widget.selectedWallet.stellarSecret).timeout( + const Duration(minutes: 1), + onTimeout: () { + throw TimeoutException('Loading active orders timed out'); + }, + ); + final ordersHistory = await getOrdersHistory( + widget.selectedWallet.stellarSecret, sellingAsset, buyingAsset) + .timeout( + const Duration(minutes: 1), + onTimeout: () { + throw TimeoutException('Loading orders history timed out'); + }, + ); + + if (activeOrders.isNotEmpty) activeOrders.clear(); + if (previousOrders.isNotEmpty) previousOrders.clear(); + final filteredActiveOrders = currentOrders.where((order) => + (order.sellingAsset == 'USDC' && order.buyingAsset == 'TFT')); + + activeOrders.addAll(filteredActiveOrders); + previousOrders.addAll(ordersHistory); + } on TimeoutException catch (e) { + _handleFailure( + 'Loading orders timed out. Please check your network', + error: e, + ); + } catch (e) { + _handleFailure( + 'Failed to load orders. Please try again.', + error: e, + ); + } finally { + setState(() { + loading = false; + }); + } + } + + void _handleFailure(String userMessage, {Object? error}) { + if (error != null) { + logger.e('Load proposals failed', error: error); + } + + if (mounted) { + final errorSnackbar = SnackBar( + content: Text( + userMessage, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).colorScheme.errorContainer), + ), + duration: const Duration(seconds: 3), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(errorSnackbar); + } + + setState(() { + loading = false; + failed = true; + }); + } + + void _setLoadingState() { + setState(() { + loading = true; + failed = false; + }); + } + + @override + Widget build(BuildContext context) { + Widget content; + if (loading) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 15), + Text( + 'Loading Orders...', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold), + ), + ], + )); + } else if (failed) { + content = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + onPressed: () { + loadOrders(); + }, + ), + const SizedBox(height: 16), + ], + ), + ); + } else { + content = DefaultTabController( + length: 2, + child: Column( + children: [ + PreferredSize( + preferredSize: const Size.fromHeight(10.0), + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: TabBar( + controller: _tabController, + labelColor: Theme.of(context).colorScheme.primary, + indicatorColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of(context).colorScheme.onSurface, + dividerColor: Theme.of(context).scaffoldBackgroundColor, + labelStyle: Theme.of(context).textTheme.titleMedium, + unselectedLabelStyle: Theme.of(context).textTheme.titleMedium, + tabs: const [ + Tab(text: 'Active Order'), + Tab(text: 'Trade History'), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + RefreshIndicator( + onRefresh: loadOrders, + child: OrdersWidget( + offers: activeOrders, + active: true, + selectedWallet: widget.selectedWallet, + )), + RefreshIndicator( + onRefresh: loadOrders, + child: OrdersWidget( + offers: previousOrders, + selectedWallet: widget.selectedWallet, + )), + ], + ), + ), + ], + ), + ); + } + return Scaffold(appBar: AppBar(title: const Text('Orders')), body: content); + } +} diff --git a/app/lib/screens/market/order_book.dart b/app/lib/screens/market/order_book.dart new file mode 100644 index 000000000..c805d32b2 --- /dev/null +++ b/app/lib/screens/market/order_book.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/order_book.dart'; +import 'package:threebotlogin/services/stellar_service.dart'; + +class OrderbookWidget extends StatefulWidget { + const OrderbookWidget({super.key}); + + @override + State createState() => _OrderbookWidgetState(); +} + +class _OrderbookWidgetState extends State { + Stream? _orderBookStream; + bool _isLoading = true; + bool failed = false; + + @override + void initState() { + super.initState(); + _loadOrderBook(); + } + + void _loadOrderBook() async { + _setLoadingState(); + + try { + final connectivityResult = await (Connectivity().checkConnectivity()); + if (connectivityResult.contains(ConnectivityResult.none)) { + _handleFailure( + 'No internet connection. Please check your network.', + ); + return; + } + + _orderBookStream = await listOrderBook( + AssetTypeCreditAlphaNum4(tftAssetCode, tftAssetIssuer), + AssetTypeCreditAlphaNum4(usdcAssetCode, usdcAssetIssuer), + ).timeout( + const Duration(minutes: 1), + onTimeout: () { + throw TimeoutException('Loading orderbook timed out'); + }, + ); + + _orderBookStream!.first.timeout( + const Duration(seconds: 15), + onTimeout: () { + throw TimeoutException('No data received from orderbook stream'); + }, + ); + setState(() { + _isLoading = false; + failed = false; + }); + } on TimeoutException catch (e) { + _handleFailure( + 'Loading orderbook timed out. Please check your network', + error: e, + ); + } catch (e) { + _handleFailure( + 'Failed to load orderbook. Please try again.', + error: e, + ); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _setLoadingState() { + setState(() { + _isLoading = true; + failed = false; + }); + } + + void _handleFailure(String userMessage, {Object? error}) { + if (error != null) { + logger.e('Load orderbook failed', error: error); + } + + if (mounted) { + final errorSnackbar = SnackBar( + content: Text( + userMessage, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).colorScheme.errorContainer), + ), + duration: const Duration(seconds: 3), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(errorSnackbar); + } + + setState(() { + _isLoading = false; + failed = true; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading orderbook...'), + ], + ), + ); + } + + if (failed) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + onPressed: _loadOrderBook, + ), + ], + ), + ); + } + return _orderBookStream == null + ? const Center(child: CircularProgressIndicator()) + : StreamBuilder( + stream: _orderBookStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final orderBook = snapshot.data!; + return _buildOrderBookTable(orderBook); + }, + ); + } + + Widget _buildOrderBookTable(OrderBook orderBook) { + final int maxRows = orderBook.bids.length > orderBook.asks.length + ? orderBook.bids.length + : orderBook.asks.length; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Buy Offers', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary), + ), + ), + ), + Container( + width: 1, + height: 30, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Sell Offers', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error), + ), + ), + ), + ], + ), + Divider( + thickness: 1, + color: Theme.of(context).colorScheme.onSurfaceVariant), + Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text('Amount (TFT)', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface)), + Text('Price (USDC)', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ), + Container( + width: 1, + height: 30, + color: Theme.of(context).colorScheme.onSurfaceVariant), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text('Amount (TFT)', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface)), + Text('Price (USDC)', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Expanded( + child: ListView.builder( + itemCount: maxRows, + itemBuilder: (context, index) { + final bid = + index < orderBook.bids.length ? orderBook.bids[index] : null; + final ask = + index < orderBook.asks.length ? orderBook.asks[index] : null; + + return Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + bid != null + ? (double.tryParse(bid.price) != null && + double.parse(bid.price) > 0) + ? (double.parse(bid.amount) / + double.parse(bid.price)) + .toStringAsFixed(7) + : '' + : '', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .primary)), + Text( + bid != null + ? (double.tryParse(bid.price) != null && + double.parse(bid.price) > 0) + ? bid.price.toString() + : '' + : '', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: Theme.of(context) + .colorScheme + .primary)), + ], + ), + ), + ), + Container( + width: 1, + height: 30, + color: Theme.of(context).colorScheme.onSurfaceVariant), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text(ask != null ? ask.amount.toString() : '', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: + Theme.of(context).colorScheme.error)), + Text(ask != null ? ask.price.toString() : '', + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith( + color: + Theme.of(context).colorScheme.error)), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/app/lib/screens/market/order_details.dart b/app/lib/screens/market/order_details.dart new file mode 100644 index 000000000..a56e425a0 --- /dev/null +++ b/app/lib/screens/market/order_details.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:stellar_client/models/exceptions.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/services/stellar_service.dart' as Stellar; +import 'package:threebotlogin/widgets/custom_dialog.dart'; +import 'package:threebotlogin/screens/market/buy_tft.dart'; +import 'package:threebotlogin/providers/orders_notifier.dart'; + +class OrderDetailsWidget extends StatefulWidget { + final Wallet selectedWallet; + final Offer offer; + final bool active; + + const OrderDetailsWidget( + {super.key, + required this.selectedWallet, + required this.offer, + required this.active}); + + @override + _OrderDetailsWidgetState createState() => _OrderDetailsWidgetState(); +} + +class _OrderDetailsWidgetState extends State { + bool loading = false; + + @override + void initState() { + super.initState(); + } + + _cancelOrder() async { + final bool shouldCancel = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext customContext) => CustomDialog( + type: DialogType.Warning, + image: Icons.warning, + title: 'Cancel Order', + description: 'Are you sure you want to cancel this order?', + actions: [ + TextButton( + child: const Text('No'), + onPressed: () { + Navigator.pop(customContext, false); + }, + ), + TextButton( + child: const Text('Yes'), + onPressed: () { + Navigator.pop(customContext, true); + }, + ), + ], + ), + ) ?? + false; + + if (!shouldCancel) return; + setState(() { + loading = true; + }); + try { + final success = await Stellar.cancelOrder( + widget.selectedWallet.stellarSecret, widget.offer.id); + OrderNotifier.emitUpdate(); + if (success) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.check, + title: 'Success!', + description: 'Your order has been cancelled successfully.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ) + ]), + ); + } else { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Failed', + description: 'Failed to cancel your order.', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ) + ]), + ); + } + } on StellarBalanceException catch (_) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Balance Error', + type: DialogType.Error, + description: + 'You need to fund your account with some XLMs to cancel your order', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } catch (e) { + showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) => CustomDialog( + image: Icons.error, + title: 'Error', + type: DialogType.Error, + description: 'Error cancelling your order: ${e.toString()}', + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ) + ]), + ); + return; + } finally { + setState(() { + loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final double amount = double.parse(widget.offer.amount); + final double pricePerTFT = double.parse(widget.offer.price); + final double pricePerUSDC = pricePerTFT > 0 ? 1 / pricePerTFT : 0; + final double totalCost = amount * pricePerTFT; + return Scaffold( + appBar: AppBar(title: const Text('Order Details')), + body: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/usdc-icon.png', + color: Theme.of(context).colorScheme.onSurface, + width: 40, + height: 40, + ), + const SizedBox(width: 10), + Image.asset( + 'assets/tf_chain.png', + color: Theme.of(context).colorScheme.onSurface, + width: 40, + height: 40, + ), + ], + ), + const SizedBox(height: 10), + Text( + 'USDC / TFT', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Buy ', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + widget.active + ? Text( + '(Active)', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurface, + ), + ) + : Text( + '(Inactive)', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurface, + ), + ), + ], + ), + ], + ), + ), + ), + customDivider(context: context), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Buy Amount', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + '${totalCost.toStringAsFixed(2)} TFT', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox( + height: 10, + ), + const SizedBox( + height: 10, + ), + Text( + 'Price Per TFT', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + '${pricePerUSDC.toString()} USDC', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox( + height: 10, + ), + Text( + 'Price Per USDC', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + '${pricePerTFT.toStringAsFixed(2)} TFT', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox( + height: 10, + ), + Text( + 'Sell Amount', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + '${amount.toStringAsFixed(2)} USDC', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ), + customDivider(context: context), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Last Updated', + style: + Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + Text( + widget.offer.lastModifiedTime, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface, + ), + ) + ]))), + ], + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.all(20), + child: widget.active + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width - 40, + child: ElevatedButton( + onPressed: () async { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BuyTFTWidget( + wallet: widget.selectedWallet, + offer: widget.offer, + edit: true, + ))); + }, + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + ), + child: Text( + 'Edit Order', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: MediaQuery.of(context).size.width - 40, + child: ElevatedButton( + onPressed: () async { + _cancelOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.errorContainer, + ), + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + )) + : Text( + 'Cancel Order', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + ], + ) + : null, + ), + ); + } + + Widget customDivider({ + required BuildContext context, + }) { + return Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.85, + child: const Divider( + thickness: 0.5, + color: Colors.grey, + ), + ), + ); + } +} diff --git a/app/lib/screens/market/overview.dart b/app/lib/screens/market/overview.dart new file mode 100644 index 000000000..d65fbfa70 --- /dev/null +++ b/app/lib/screens/market/overview.dart @@ -0,0 +1,682 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/market_data.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/providers/wallets_provider.dart'; +import 'package:threebotlogin/screens/market/buy_tft.dart'; +import 'package:threebotlogin/screens/market/order.dart'; +import 'package:threebotlogin/widgets/market/wallet_selection.dart'; +import 'package:threebotlogin/services/stellar_service.dart' as Stellar; + +class OverviewWidget extends ConsumerStatefulWidget { + const OverviewWidget({super.key}); + + @override + ConsumerState createState() => _OverviewWidgetState(); +} + +class _OverviewWidgetState extends ConsumerState { + late Timer _timer; + String lastUpdated = '--'; + Wallet? _selectedWallet; + bool loading = true; + bool failed = false; + bool isLoadingWallets = false; + bool loadingWalletsFailed = false; + TftMarketData? marketData; + + @override + void initState() { + super.initState(); + _startPriceUpdater(); + _fetchMarketData(); + _checkWalletsListed(); + } + + Future _checkWalletsListed() async { + final walletsNotifierRef = ref.read(walletsNotifier.notifier); + if (!walletsNotifierRef.isListed) { + setState(() { + isLoadingWallets = true; + loadingWalletsFailed = false; + _selectedWallet = null; + }); + try { + await walletsNotifierRef.list(); + } catch (e) { + if (mounted) { + setState(() { + loadingWalletsFailed = true; + }); + } + } finally { + if (mounted) { + setState(() { + isLoadingWallets = false; + }); + } + } + } + } + + Future _retryLoadingWallets() async { + setState(() { + isLoadingWallets = true; + loadingWalletsFailed = false; + _selectedWallet = null; + }); + + final walletsNotifierRef = ref.read(walletsNotifier.notifier); + + try { + await walletsNotifierRef.list(); + if (mounted && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Wallets loaded successfully', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.primaryContainer), + ), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + loadingWalletsFailed = true; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to load wallets. Please check your connection.', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.errorContainer), + ), + duration: const Duration(seconds: 3), + action: SnackBarAction( + label: 'Retry', + textColor: Theme.of(context).colorScheme.errorContainer, + onPressed: () { + _retryLoadingWallets(); + }, + ), + ), + ); + } + } finally { + if (mounted) { + setState(() { + isLoadingWallets = false; + }); + } + } + } + + Future _fetchMarketData() async { + setState(() { + loading = true; + failed = false; + }); + try { + final data = await Stellar.fetchTftMarketData(); + if (data == null) { + setState(() { + failed = true; + marketData = TftMarketData.empty(); + }); + logger.e('Error fetching market data: received null data'); + return null; + } + setState(() { + marketData = data; + failed = false; + }); + lastUpdated = _formattedDateTime(); + return data; + } catch (e) { + setState(() { + failed = true; + marketData = TftMarketData.empty(); + }); + logger.e('Error fetching market data: $e'); + return null; + } finally { + setState(() { + loading = false; + }); + } + } + + void _startPriceUpdater() { + _timer = Timer.periodic(const Duration(minutes: 5), (timer) { + _fetchMarketData(); + }); + } + + String _formattedDateTime() { + final now = DateTime.now(); + return '${now.year}-${_twoDigits(now.month)}-${_twoDigits(now.day)} ' + '${_twoDigits(now.hour)}:${_twoDigits(now.minute)}:${_twoDigits(now.second)}'; + } + + String _twoDigits(int n) => n.toString().padLeft(2, '0'); + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wallets = ref.watch(walletsNotifier); + Widget mainWidget; + if (loading || isLoadingWallets) { + mainWidget = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 15), + Text( + 'Loading Market...', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold), + ), + ], + )); + } else if (failed || loadingWalletsFailed || marketData == null) { + mainWidget = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 15), + ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + onPressed: () { + setState(() { + failed = false; + loading = true; + }); + _fetchMarketData(); + _retryLoadingWallets(); + }, + ), + ], + ), + ); + } else { + mainWidget = RefreshIndicator( + onRefresh: handleRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + width: double.infinity, + margin: EdgeInsets.symmetric( + horizontal: + MediaQuery.of(context).size.width * 0.04, + vertical: 8), + padding: const EdgeInsets.all(14.0), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/usdc-icon.png', + color: Theme.of(context).colorScheme.onSurface, + width: 30, + height: 30, + ), + const SizedBox(width: 8), + Text( + 'USDC', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + const Spacer(), + SizedBox( + width: 50, + child: Center( + child: GestureDetector( + onTap: null, + child: CircleAvatar( + radius: 25, + backgroundColor: Theme.of(context) + .colorScheme + .primaryContainer, + child: Icon( + Icons.arrow_forward, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + size: 30, + ), + ), + ), + ), + ), + const Spacer(), + Image.asset( + 'assets/tf_chain.png', + color: Theme.of(context).colorScheme.onSurface, + width: 30, + height: 30, + ), + const SizedBox(width: 8), + Text( + 'TFT', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith( + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + ], + )), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + marketData!.lastPrice.toStringAsFixed(7), + style: Theme.of(context) + .textTheme + .headlineLarge! + .copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox( + width: 5, + ), + Text( + 'USDC', + style: Theme.of(context) + .textTheme + .headlineLarge! + .copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Last updated: $lastUpdated', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 16), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 50, + child: ElevatedButton( + onPressed: () => + _openWalletSelectionOverlay(wallets), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _selectedWallet?.name ?? + 'Select Wallet', + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ), + Icon(Icons.arrow_drop_down, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _selectedWallet == null + ? null + : () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + OrderWidget( + selectedWallet: + _selectedWallet!, + ))); + }, + child: Text( + 'My Orders', + style: _selectedWallet == null + ? Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant) + : Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + ), + ], + )), + SizedBox( + width: double.infinity, + child: Column( + children: [ + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: Theme.of(context).colorScheme.primary), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Market Stats', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer), + ), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildMarketColumn('Last Price', + '${marketData!.lastPrice.toStringAsFixed(7)} USDC'), + _buildMarketColumn('Last USD Price', + '\$${marketData!.lastUsdPrice.toStringAsFixed(7)}'), + _buildMarketColumn('24H Change', + '${marketData!.change24h.toStringAsFixed(7)}%'), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildMarketColumn('24H High', + '${marketData!.high24h.toStringAsFixed(7)} USDC'), + _buildMarketColumn('24H Low', + '${marketData!.low24h.toStringAsFixed(7)} USDC'), + _buildMarketColumn('24H Volume', + '${marketData!.volume24h.toStringAsFixed(7)}K USDC'), + ], + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (_selectedWallet != null) + SizedBox( + width: double.infinity, + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .primary), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Balance', + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer), + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildMarketColumn( + 'TFT Balance', + _selectedWallet + ?.stellarBalances['TFT']!), + _buildMarketColumn( + 'USDC Balance', + _selectedWallet + ?.stellarBalances['USDC']!), + _buildMarketColumn( + 'XLM Balance', + _selectedWallet + ?.stellarBalances['XLM']!), + ], + ), + ], + ), + ), + ), + ) + ], + ), + ), + const SizedBox(height: 16), + Center( + child: SizedBox( + width: MediaQuery.of(context).size.width - 40, + child: ElevatedButton( + onPressed: _selectedWallet == null + ? null + : () async { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => BuyTFTWidget( + wallet: _selectedWallet!, + edit: false, + ))); + }, + child: Text( + 'Buy TFT', + style: _selectedWallet == null + ? Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant) + : Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + ), + ], + ), + ])); + } + return mainWidget; + } + + Future handleRefresh() async { + try { + setState(() { + loading = true; + failed = false; + }); + + final marketData = await _fetchMarketData(); + if (!mounted) return; + + setState(() { + this.marketData = marketData; + lastUpdated = _formattedDateTime(); + failed = false; + }); + } catch (e) { + setState(() { + failed = true; + }); + logger.i('Error fetching price: $e'); + } finally { + setState(() { + loading = false; + }); + } + } + + Widget _buildMarketColumn(String title, String? value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondaryContainer), + ), + const SizedBox(height: 4), + Text(value ?? 'Loading...', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer)), + ], + ), + ); + } + + _openWalletSelectionOverlay(List wallets) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + isDismissible: true, + constraints: const BoxConstraints(maxWidth: double.infinity), + builder: (context) { + final filteredWallets = wallets + .where((wallet) => + double.parse(wallet.stellarBalances['TFT']!) >= 0 && + double.parse(wallet.stellarBalances['USDC']!) >= 0) + .toList(); + + if (filteredWallets.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No Wallets Available', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + const SizedBox(height: 12), + Text( + 'No wallets with TFT and USDC assets found.', + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).colorScheme.onSurface), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ], + ), + ); + } + return WalletSelectionSheet( + wallets: filteredWallets, + selectedWallet: _selectedWallet, + onWalletSelected: (Wallet wallet) { + setState(() { + _selectedWallet = wallet; + }); + Navigator.pop(context); + }, + ); + }, + ); + } +} diff --git a/app/lib/screens/registered_screen.dart b/app/lib/screens/registered_screen.dart index eccddf3b5..221d50769 100644 --- a/app/lib/screens/registered_screen.dart +++ b/app/lib/screens/registered_screen.dart @@ -98,7 +98,7 @@ class _RegisteredScreenState extends State icon: Icons.how_to_vote_outlined, pageNumber: 4), HomeCardWidget( - name: 'Sign', icon: Icons.draw_sharp, pageNumber: 8), + name: 'Sign', icon: Icons.draw_sharp, pageNumber: 9), ], ), const Row( @@ -106,15 +106,19 @@ class _RegisteredScreenState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ HomeCardWidget( - name: 'News', icon: Icons.article, pageNumber: 1), + name: 'Market', + icon: Icons.show_chart_sharp, + pageNumber: 6), HomeCardWidget( - name: 'Identity', icon: Icons.person, pageNumber: 5), + name: 'News', icon: Icons.article, pageNumber: 1), ], ), const Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ + HomeCardWidget( + name: 'Identity', icon: Icons.person, pageNumber: 5), HomeCardWidget( name: 'Settings', icon: Icons.settings, pageNumber: 6), // HomeCardWidget( diff --git a/app/lib/screens/signing/sign_with_link.dart b/app/lib/screens/signing/sign_with_link.dart index 54007ca2c..4d1f765d6 100644 --- a/app/lib/screens/signing/sign_with_link.dart +++ b/app/lib/screens/signing/sign_with_link.dart @@ -296,7 +296,7 @@ class _SignWithLinkScreenState extends ConsumerState : Text('Sign', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context) .colorScheme diff --git a/app/lib/screens/signing/sign_with_qrcode.dart b/app/lib/screens/signing/sign_with_qrcode.dart index e07edf0e4..a33c5c2bb 100644 --- a/app/lib/screens/signing/sign_with_qrcode.dart +++ b/app/lib/screens/signing/sign_with_qrcode.dart @@ -168,7 +168,7 @@ class _SignWithQRCodeScreenState extends ConsumerState 'Scan QR Code', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), @@ -240,7 +240,7 @@ class _SignWithQRCodeScreenState extends ConsumerState : Text('Sign', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context) .colorScheme diff --git a/app/lib/screens/signing/sign_with_text.dart b/app/lib/screens/signing/sign_with_text.dart index 6bde9d54c..adcce41d8 100644 --- a/app/lib/screens/signing/sign_with_text.dart +++ b/app/lib/screens/signing/sign_with_text.dart @@ -101,7 +101,7 @@ class _SignWithTextScreenState extends ConsumerState : Text('Sign', style: Theme.of(context) .textTheme - .titleLarge + .titleMedium !.copyWith( color: Theme.of(context) .colorScheme diff --git a/app/lib/screens/signing/signing.dart b/app/lib/screens/signing/signing.dart index a87741125..02e6e7656 100644 --- a/app/lib/screens/signing/signing.dart +++ b/app/lib/screens/signing/signing.dart @@ -59,7 +59,7 @@ class _SigningState extends State { const SizedBox(height: 32), Text( 'Choose Signing Method', - style: Theme.of(context).textTheme.headlineSmall!.copyWith( + style: Theme.of(context).textTheme.titleLarge!.copyWith( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface, ), diff --git a/app/lib/screens/wallets/bridge.dart b/app/lib/screens/wallets/bridge.dart index febc6617b..6754009ab 100644 --- a/app/lib/screens/wallets/bridge.dart +++ b/app/lib/screens/wallets/bridge.dart @@ -68,8 +68,8 @@ class _WalletBridgeScreenState extends ConsumerState { } _loadStellarBalance() async { - widget.wallet.stellarBalance = - (await Stellar.getBalance(widget.wallet.stellarSecret)).toString(); + widget.wallet.stellarBalances['TFT'] = + (await Stellar.getTFTBalance(widget.wallet.stellarSecret)).toString(); setState(() {}); } @@ -81,7 +81,7 @@ class _WalletBridgeScreenState extends ConsumerState { .read(walletsNotifier.notifier); final wallet = walletRef.getUpdatedWallet(widget.wallet.name)!; widget.wallet.tfchainBalance = wallet.tfchainBalance; - widget.wallet.stellarBalance = wallet.stellarBalance; + widget.wallet.stellarBalances['TFT'] = wallet.stellarBalances['TFT']!; setState(() {}); await Future.delayed(Duration(seconds: refreshBalance)); await _reloadBalances(); @@ -180,7 +180,7 @@ class _WalletBridgeScreenState extends ConsumerState { } final balance = roundAmount(isWithdraw ? widget.wallet.tfchainBalance - : widget.wallet.stellarBalance); + : widget.wallet.stellarBalances['TFT']!); if (balance - Decimal.parse(amount) - totalFee < Decimal.zero) { amountError = 'Insufficient balance (fees included).'; return false; @@ -210,14 +210,14 @@ class _WalletBridgeScreenState extends ConsumerState { Widget build(BuildContext context) { List wallets = ref.read(walletsNotifier); final bool disableDeposit = - double.parse(widget.wallet.stellarBalance) <= -1; + double.parse(widget.wallet.stellarBalances['TFT']!) <= -1; if (disableDeposit && !isWithdraw) { onTransactionChange(BridgeOperation.Withdraw); } String balance = isWithdraw ? widget.wallet.tfchainBalance - : widget.wallet.stellarBalance; + : widget.wallet.stellarBalances['TFT']!; final isBiggerThanFee = roundAmount(balance) > totalFee; return Scaffold( @@ -309,8 +309,9 @@ class _WalletBridgeScreenState extends ConsumerState { wallets: isWithdraw ? wallets .where((w) => - double.parse(w - .stellarBalance) >= + double.parse( + w.stellarBalances[ + 'TFT']!) >= 0) .toList() : wallets, @@ -386,7 +387,7 @@ class _WalletBridgeScreenState extends ConsumerState { 'Submit', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context).colorScheme.primary, @@ -405,7 +406,7 @@ class _WalletBridgeScreenState extends ConsumerState { calculateAmount(int percentage) { final amount = (Decimal.parse(isWithdraw ? widget.wallet.tfchainBalance - : widget.wallet.stellarBalance) - + : widget.wallet.stellarBalances['TFT']!) - totalFee) * (Decimal.fromInt(percentage).shift(-2)); amountController.text = roundAmount(amount.toString()).toString(); diff --git a/app/lib/screens/wallets/contacts.dart b/app/lib/screens/wallets/contacts.dart index f98ff4659..988da1983 100644 --- a/app/lib/screens/wallets/contacts.dart +++ b/app/lib/screens/wallets/contacts.dart @@ -28,7 +28,7 @@ class _ContactsScreenState extends State { _loadMyWalletContacts() { for (final w in widget.wallets) { - if (double.parse(w.stellarBalance) >= 0) { + if (double.parse(w.stellarBalances['TFT']!) >= 0) { myWalletContacts.add(PkidContact( name: w.name, address: w.stellarAddress, type: ChainType.Stellar)); } @@ -125,7 +125,7 @@ class _ContactsScreenState extends State { unselectedLabelColor: Theme.of(context).colorScheme.onSurface, dividerColor: Theme.of(context).scaffoldBackgroundColor, - labelStyle: Theme.of(context).textTheme.titleLarge, + labelStyle: Theme.of(context).textTheme.titleMedium, unselectedLabelStyle: Theme.of(context).textTheme.titleMedium, tabs: const [ diff --git a/app/lib/screens/wallets/receive.dart b/app/lib/screens/wallets/receive.dart index 98c147c78..e191bcb77 100644 --- a/app/lib/screens/wallets/receive.dart +++ b/app/lib/screens/wallets/receive.dart @@ -86,7 +86,7 @@ class _WalletReceiveScreenState extends State { @override Widget build(BuildContext context) { - final bool hideStellar = double.parse(widget.wallet.stellarBalance) <= -1; + final bool hideStellar = double.parse(widget.wallet.stellarBalances['TFT']!) <= -1; if (hideStellar) { onChangeChain(ChainType.TFChain); } @@ -166,7 +166,7 @@ class _WalletReceiveScreenState extends State { 'Generate QR Code', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), diff --git a/app/lib/screens/wallets/send.dart b/app/lib/screens/wallets/send.dart index 80f5a6417..6997833e7 100644 --- a/app/lib/screens/wallets/send.dart +++ b/app/lib/screens/wallets/send.dart @@ -73,8 +73,8 @@ class _WalletSendScreenState extends ConsumerState { } _loadStellarBalance() async { - widget.wallet.stellarBalance = - (await Stellar.getBalance(widget.wallet.stellarSecret)).toString(); + widget.wallet.stellarBalances['TFT'] = + (await Stellar.getTFTBalance(widget.wallet.stellarSecret)).toString(); setState(() {}); } @@ -86,7 +86,7 @@ class _WalletSendScreenState extends ConsumerState { .read(walletsNotifier.notifier); final wallet = walletRef.getUpdatedWallet(widget.wallet.name)!; widget.wallet.tfchainBalance = wallet.tfchainBalance; - widget.wallet.stellarBalance = wallet.stellarBalance; + widget.wallet.stellarBalances['TFT'] = wallet.stellarBalances['TFT']!; setState(() {}); await Future.delayed(Duration(seconds: refreshBalance)); await _reloadBalances(); @@ -145,7 +145,8 @@ class _WalletSendScreenState extends ConsumerState { wallets.where((wallet) => wallet.stellarAddress == toAddress); final Wallet? wallet = matchingWallets.isNotEmpty ? matchingWallets.first : null; - if (wallet != null && double.parse(wallet.stellarBalance) <= -1) { + if (wallet != null && + double.parse(wallet.stellarBalances['TFT']!) <= -1) { setState(() { toAddressError = 'Wallet not activated on stellar'; }); @@ -184,7 +185,7 @@ class _WalletSendScreenState extends ConsumerState { } final balance = roundAmount(chainType == ChainType.TFChain ? widget.wallet.tfchainBalance - : widget.wallet.stellarBalance); + : widget.wallet.stellarBalances['TFT']!); if (balance - Decimal.parse(amount) - fee < Decimal.zero) { amountError = 'Balance is not enough'; @@ -218,12 +219,13 @@ class _WalletSendScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final bool hideStellar = double.parse(widget.wallet.stellarBalance) <= -1; + final bool hideStellar = + double.parse(widget.wallet.stellarBalances['TFT']!) <= -1; if (hideStellar && chainType == ChainType.Stellar) { onChangeChain(ChainType.TFChain); } String balance = chainType == ChainType.Stellar - ? widget.wallet.stellarBalance + ? widget.wallet.stellarBalances['TFT']! : widget.wallet.tfchainBalance; final isBiggerThanFee = roundAmount(balance) > fee; @@ -265,7 +267,7 @@ class _WalletSendScreenState extends ConsumerState { 'Scan QR Code', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), @@ -431,7 +433,7 @@ class _WalletSendScreenState extends ConsumerState { 'Transfer', style: Theme.of(context) .textTheme - .titleLarge! + .titleMedium! .copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), @@ -518,7 +520,7 @@ class _WalletSendScreenState extends ConsumerState { calculateAmount(int percentage) { final amount = (Decimal.parse(chainType == ChainType.TFChain ? widget.wallet.tfchainBalance - : widget.wallet.stellarBalance) - + : widget.wallet.stellarBalances['TFT']!) - fee) * (Decimal.fromInt(percentage).shift(-2)); amountController.text = roundAmount(amount.toString()).toString(); diff --git a/app/lib/screens/wallets/transactions.dart b/app/lib/screens/wallets/transactions.dart index bf4519894..7c196693e 100644 --- a/app/lib/screens/wallets/transactions.dart +++ b/app/lib/screens/wallets/transactions.dart @@ -67,7 +67,7 @@ class _WalletTransactionsWidgetState extends State { @override void initState() { super.initState(); - if (double.parse(widget.wallet.stellarBalance) > -1) { + if (double.parse(widget.wallet.stellarBalances['TFT']!) > -1) { _pagingController.addPageRequestListener(_listTransactions); } } @@ -80,7 +80,7 @@ class _WalletTransactionsWidgetState extends State { @override Widget build(BuildContext context) { - if (double.parse(widget.wallet.stellarBalance) <= -1) { + if (double.parse(widget.wallet.stellarBalances['TFT']!) <= -1) { return Center( child: Text( 'No transactions yet.', diff --git a/app/lib/screens/wallets/wallet_assets.dart b/app/lib/screens/wallets/wallet_assets.dart index 37a8dd2ba..7f8147799 100644 --- a/app/lib/screens/wallets/wallet_assets.dart +++ b/app/lib/screens/wallets/wallet_assets.dart @@ -41,7 +41,7 @@ class _WalletAssetsWidgetState extends State { .read(walletsNotifier.notifier); final wallet = walletRef.getUpdatedWallet(widget.wallet.name)!; widget.wallet.tfchainBalance = wallet.tfchainBalance; - widget.wallet.stellarBalance = wallet.stellarBalance; + widget.wallet.stellarBalances['TFT'] = wallet.stellarBalances['TFT']!; setState(() {}); await Future.delayed(Duration(seconds: refreshBalance)); await _reloadBalances(); @@ -132,7 +132,7 @@ class _WalletAssetsWidgetState extends State { const SizedBox(height: 10), Text( 'Send', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.primary), ), ], @@ -161,7 +161,7 @@ class _WalletAssetsWidgetState extends State { const SizedBox(height: 10), Text( 'Receive', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.primary), ), ], @@ -191,7 +191,7 @@ class _WalletAssetsWidgetState extends State { const SizedBox(height: 10), Text( 'Bridge', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.primary), ), ], @@ -210,10 +210,10 @@ class _WalletAssetsWidgetState extends State { const SizedBox( height: 20, ), - if (double.parse(widget.wallet.stellarBalance) >= 0) + if (double.parse(widget.wallet.stellarBalances['TFT']!) >= 0) WalletBalanceTileWidget( name: ChainType.Stellar, - balance: formatAmount(widget.wallet.stellarBalance), + balance: formatAmount(widget.wallet.stellarBalances['TFT']!), loading: stellarBalaceLoading, ), const SizedBox(height: 10), @@ -224,10 +224,10 @@ class _WalletAssetsWidgetState extends State { loading: tfchainBalaceLoading, ), const SizedBox(height: 10), - if (double.parse(widget.wallet.stellarBalance) <= -1) + if (double.parse(widget.wallet.stellarBalances['TFT']!) <= -1) WalletBalanceTileWidget( name: ChainType.Stellar, - balance: widget.wallet.stellarBalance, + balance: widget.wallet.stellarBalances['TFT']!, loading: false, onActivate: _openActivateStellarOverlay, ), diff --git a/app/lib/services/stellar_service.dart b/app/lib/services/stellar_service.dart index dbfee44c4..8bc07ba25 100644 --- a/app/lib/services/stellar_service.dart +++ b/app/lib/services/stellar_service.dart @@ -6,8 +6,19 @@ import 'package:stellar_client/models/vesting_account.dart'; import 'package:stellar_client/stellar_client.dart'; import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/market_data.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:threebotlogin/models/order_book.dart'; import 'package:http/http.dart' as http; +const String tftAssetCode = 'TFT'; +const String tftAssetIssuer = + 'GBOVQKJYHXRR3DX6NOX2RRYFRCUMSADGDESTDNBDS6CDVLGVESRTAC47'; +const String usdcAssetCode = 'USDC'; +const String usdcAssetIssuer = + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const horizonUrl = 'https://horizon.stellar.org'; + bool isValidStellarSecret(String seed) { try { StrKey.decodeStellarSecretSeed(seed); @@ -28,27 +39,30 @@ bool isValidStellarAddress(String address) { return false; } -Future getBalanceByClient(Client client) async { +Future> getBalanceByClient(Client client) async { try { final stellarBalances = await client.getBalance(); + final balances = {'TFT': '-1', 'USDC': '-1', 'XLM': '-1'}; + for (final balance in stellarBalances) { - if (balance.assetCode == 'TFT') { - if (double.parse(balance.balance) == 0) return '0'; - return balance.balance; + if (balance.assetCode == 'TFT' || + balance.assetCode == 'USDC' || + balance.assetCode == 'XLM') { + balances[balance.assetCode] = + double.parse(balance.balance) == 0 ? '0' : balance.balance; } } + return balances; } catch (e) { logger.i("Couldn't load the account balance due to $e"); - // -2 means that the account not activated on stellar - return '-2'; + return {'TFT': '-2', 'USDC': '-2', 'XLM': '-2'}; } - // -1 means that account activated but no TFT trustline - return '-1'; } -Future getBalance(String secret) async { +Future getTFTBalance(String secret) async { final client = Client(NetworkType.PUBLIC, secret); - return getBalanceByClient(client); + final balances = await getBalanceByClient(client); + return balances['TFT'] ?? '-1'; } Stream listTransactions( @@ -105,14 +119,10 @@ Future getBalanceByAccountId(String accountId) async { } Future getTFTPriceFromXLM() async { - const String baseUrl = 'https://horizon.stellar.org'; - const String counterAssetCode = 'TFT'; - const String counterAssetIssuer = - 'GBOVQKJYHXRR3DX6NOX2RRYFRCUMSADGDESTDNBDS6CDVLGVESRTAC47'; - final String requestUrl = '$baseUrl/trades?base_asset_type=native' + final String requestUrl = '$horizonUrl/trades?base_asset_type=native' '&counter_asset_type=credit_alphanum4' - '&counter_asset_code=$counterAssetCode' - '&counter_asset_issuer=$counterAssetIssuer' + '&counter_asset_code=$tftAssetCode' + '&counter_asset_issuer=$tftAssetIssuer' '&order=desc&limit=1'; try { @@ -150,3 +160,173 @@ Future addTFTTrustline(String secret, String assetCode) async { final client = Client(NetworkType.PUBLIC, secret); return await client.addTrustLineThroughThreefoldService(assetCode); } + +Future> listOrderBook( + Asset sellingAsset, Asset buyingAsset) async { + final stream = await getOrderBook( + horizonUrl: horizonUrl, + sellingAsset: sellingAsset, + buyingAsset: buyingAsset); + + return stream.map((orderBookResponse) { + return OrderBook( + base: orderBookResponse.base.toString(), + counter: orderBookResponse.counter.toString(), + bids: orderBookResponse.bids + .map((offer) => OrderOffer( + amount: offer.amount, + price: offer.price, + priceR: PriceR( + numerator: offer.priceR.numerator!, + denominator: offer.priceR.denominator!, + ), + )) + .toList(), + asks: orderBookResponse.asks + .map((offer) => OrderOffer( + amount: offer.amount, + price: offer.price, + priceR: PriceR( + numerator: offer.priceR.numerator!, + denominator: offer.priceR.denominator!, + ), + )) + .toList(), + ); + }); +} + +Future getLastTradedTFTPrice() async { + final String requestUrl = + '$horizonUrl/trades?base_asset_type=credit_alphanum4' + '&base_asset_code=$usdcAssetCode' + '&base_asset_issuer=$usdcAssetIssuer' + '&counter_asset_type=credit_alphanum4' + '&counter_asset_code=$tftAssetCode' + '&counter_asset_issuer=$tftAssetIssuer' + '&order=desc&limit=1'; + + try { + final response = await http.get(Uri.parse(requestUrl)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List trades = data['_embedded']?['records'] ?? []; + + if (trades.isNotEmpty) { + final trade = trades[0]; + final double baseAmount = double.parse(trade['base_amount']); + final double counterAmount = double.parse(trade['counter_amount']); + + if (baseAmount == 0 || baseAmount.isNaN) { + logger.e('Invalid base amount: $baseAmount'); + return 0; + } + + final double pricePerUSDC = counterAmount / baseAmount; + if (pricePerUSDC.isInfinite || pricePerUSDC.isNaN) { + logger.e('Invalid price calculation: $pricePerUSDC'); + return 0; + } + return 1 / pricePerUSDC; + } else { + logger.i('No recent trades found.'); + return 0; + } + } else { + logger.e('Error fetching last traded price: ${response.statusCode}'); + throw Exception('Error getting price'); + } + } catch (e) { + logger.e('Error: $e'); + throw Exception('Error getting price'); + } +} + +Future fetchTftMarketData() async { + final url = Uri.parse('$horizonUrl/trades?' + 'base_asset_type=credit_alphanum4&base_asset_code=$usdcAssetCode&base_asset_issuer=$usdcAssetIssuer' + '&counter_asset_type=credit_alphanum4&counter_asset_code=$tftAssetCode&counter_asset_issuer=$tftAssetIssuer' + '&order=desc' + '&limit=200'); + + try { + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final List trades = data['_embedded']?['records'] ?? []; + + if (trades.isNotEmpty) { + return TftMarketData.fromTrades(trades); + } + } + + logger.i('Error: No trade data found.'); + } catch (e) { + logger.e('Error fetching market data: $e'); + } + + return null; +} + +Future> getActiveOrders(String secret) async { + final client = Client(NetworkType.PUBLIC, secret); + final orders = await client.listMyOffers(); + return orders.map((order) => Offer.fromOfferResponse(order)).toList(); +} + +Future> getOrdersHistory( + String secret, Asset sellingAsset, Asset buyingAsset) async { + final client = Client(NetworkType.PUBLIC, secret); + final orders = await getTradingHistory( + network: NetworkType.PUBLIC, + accountId: client.accountId, + baseAsset: sellingAsset, + counterAsset: buyingAsset); + return orders.map((order) => Offer.fromTradeResponse(order)).toList(); +} + +Future createOrder(String secret, String sellingAssetCode, + String buyingAssetCode, String amount, String price) async { + final client = Client(NetworkType.PUBLIC, secret); + return await client.createOrder( + sellingAssetCode: sellingAssetCode, + buyingAssetCode: buyingAssetCode, + amount: amount, + price: price); +} + +Future cancelOrder(String secret, String offerID) async { + final client = Client(NetworkType.PUBLIC, secret); + return await client.cancelOrder(offerId: offerID); +} + +Future updateOrder( + String secret, String amount, String price, String offerID) async { + final client = Client(NetworkType.PUBLIC, secret); + return await client.updateOrder( + amount: amount, price: price, offerId: offerID); +} + +// The provided offer won't be counted from available balance +Future getAvailableUSDCBalance( + String secret, Offer? currentOffer) async { + final client = Client(NetworkType.PUBLIC, secret); + final offers = (await client.listMyOffers()).where((offer) => + offer.id != currentOffer?.id && getAssetName(offer.selling) == 'USDC'); + final totalReserved = offers.fold(0, (sum, offer) { + return sum + double.parse(offer.amount); + }); + final balance = await getBalanceByClient(client); + return (double.parse(balance['USDC']!) - totalReserved).toStringAsFixed(7); +} + +String getAssetName(Asset asset) { + if (asset is AssetTypeNative) { + return 'XLM'; + } else if (asset is AssetTypeCreditAlphaNum) { + return asset.code; + } + return 'Unknown Asset'; +} diff --git a/app/lib/services/wallet_service.dart b/app/lib/services/wallet_service.dart index eca977a58..4de5f8bdf 100644 --- a/app/lib/services/wallet_service.dart +++ b/app/lib/services/wallet_service.dart @@ -115,11 +115,13 @@ Future loadWallet(String walletName, String walletSeed, WalletType walletType, String chainUrl, String idenfyServiceUrl) async { final (stellarClient, tfchainClient) = await loadWalletClients(walletName, walletSeed, walletType, chainUrl); + final balances = await Future.wait([ StellarService.getBalanceByClient(stellarClient), - TFChainService.getBalanceByClient(tfchainClient) + TFChainService.getBalanceByClient(tfchainClient), ]); - final stellarBalance = balances.first.toString(); + + final stellarBalances = balances.first as Map; final tfchainBalance = balances.last.toString() == '0.0' ? '0' : balances.last.toString(); final kycVerified = await getVerificationStatus( @@ -131,11 +133,16 @@ Future loadWallet(String walletName, String walletSeed, stellarAddress: stellarClient.accountId, tfchainSecret: tfchainClient.mnemonicOrSecretSeed, tfchainAddress: tfchainClient.address, - stellarBalance: stellarBalance, + stellarBalances: { + 'TFT': stellarBalances['TFT'] ?? '-1', + 'USDC': stellarBalances['USDC'] ?? '-1', + 'XLM': stellarBalances['XLM'] ?? '-1' + }, tfchainBalance: tfchainBalance, type: walletType, verificationStatus: kycVerified.status, ); + return wallet; } diff --git a/app/lib/widgets/add_farm.dart b/app/lib/widgets/add_farm.dart index 2191992c1..1bd40e918 100644 --- a/app/lib/widgets/add_farm.dart +++ b/app/lib/widgets/add_farm.dart @@ -112,7 +112,7 @@ class _NewFarmState extends State { }); return false; } - if (double.parse(_selectedWallet!.stellarBalance) <= -1) { + if (double.parse(_selectedWallet!.stellarBalances['TFT']!) <= -1) { setState(() { walletError = 'Wallet not activated on stellar'; }); diff --git a/app/lib/widgets/layout_drawer.dart b/app/lib/widgets/layout_drawer.dart index 82b4c3f5b..c51b7e7d7 100644 --- a/app/lib/widgets/layout_drawer.dart +++ b/app/lib/widgets/layout_drawer.dart @@ -31,7 +31,7 @@ class _LayoutDrawerState extends State { } else if (index == 2) { globals.tabController.animateTo(3); } else if (index == 3) { - globals.tabController.animateTo(6); + globals.tabController.animateTo(7); } else { return; } @@ -161,7 +161,18 @@ class _LayoutDrawerState extends State { title: const Text('Sign'), onTap: () { Navigator.pop(context); - globals.tabController.animateTo(8); + globals.tabController.animateTo(9); + }, + ), + ListTile( + minLeadingWidth: 10, + leading: const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon(Icons.show_chart_sharp, size: 18)), + title: const Text('Market'), + onTap: () { + Navigator.pop(context); + globals.tabController.animateTo(6); }, ), ListTile( @@ -183,7 +194,7 @@ class _LayoutDrawerState extends State { title: const Text('Settings'), onTap: () { Navigator.pop(context); - globals.tabController.animateTo(6); + globals.tabController.animateTo(7); }, ), if (Globals().council) @@ -195,7 +206,7 @@ class _LayoutDrawerState extends State { title: const Text('Council'), onTap: () { Navigator.pop(context); - globals.tabController.animateTo(7); + globals.tabController.animateTo(8); }, ), // ListTile( diff --git a/app/lib/widgets/market/order_card.dart b/app/lib/widgets/market/order_card.dart new file mode 100644 index 000000000..f5556db6f --- /dev/null +++ b/app/lib/widgets/market/order_card.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:intl/intl.dart'; +import 'package:threebotlogin/models/wallet.dart' as Wallet; +import 'package:threebotlogin/screens/market/order_details.dart'; + +class OrderCardWidget extends ConsumerStatefulWidget { + final Offer offer; + final Wallet.Wallet selectedWallet; + final bool active; + const OrderCardWidget( + {super.key, + required this.offer, + required this.selectedWallet, + required this.active}); + + @override + ConsumerState createState() => _OrderCardWidgetState(); +} + +class _OrderCardWidgetState extends ConsumerState { + String formatDateTime(String isoString) { + try { + DateTime dateTime = DateTime.parse(isoString).toLocal(); + return DateFormat('yyyy-MM-dd HH:mm:ss').format(dateTime); + } catch (e) { + logger.e('Error formatting date: $e'); + return 'Unknown date'; + } + } + + @override + Widget build(BuildContext context) { + try { + final double amount = double.parse(widget.offer.amount); + double pricePerTFT; + try { + pricePerTFT = double.parse(widget.offer.price) > 0 + ? 1 / double.parse(widget.offer.price) + : 0; + } catch (e) { + logger.e('Error parsing price: ${widget.offer.price}, error: $e'); + pricePerTFT = 0; + } + final double totalCost = pricePerTFT > 0 ? amount / pricePerTFT : 0; + + List cardContent = []; + + cardContent = [ + _buildInfoRow(context, 'Amount:', '- ${amount.toStringAsFixed(2)} USDC', + textColor: Theme.of(context).colorScheme.error), + _buildInfoRow( + context, 'Price per TFT:', '${pricePerTFT.toStringAsFixed(4)} USDC', + isHighlighted: true), + _buildInfoRow(context, 'Total Received:', + '+ ${(totalCost).toStringAsFixed(4)} TFT', + textColor: Theme.of(context).colorScheme.primary), + ]; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + width: 1, + ), + ), + child: InkWell( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => OrderDetailsWidget( + active: widget.active, + offer: widget.offer, + selectedWallet: widget.selectedWallet, + ), + )); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Image.asset( + 'assets/usdc-icon.png', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + SizedBox( + width: 24, + height: 24, + child: Image.asset( + 'assets/tf_chain.png', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Buy', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + const Spacer(), + Text( + formatDateTime(widget.offer.lastModifiedTime), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const Divider(height: 24), + ...cardContent, + ], + ), + ), + ), + ); + } catch (e) { + logger.e('Error building OrderCardWidget: $e'); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.2), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Error displaying order: ${e.toString()}', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ); + } + } + + Widget _buildInfoRow( + BuildContext context, + String label, + String value, { + bool isHighlighted = false, + Color? textColor, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + Text( + value, + style: isHighlighted + ? Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ) + : Theme.of(context).textTheme.bodyMedium!.copyWith( + color: textColor, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/market/orders_widget.dart b/app/lib/widgets/market/orders_widget.dart new file mode 100644 index 000000000..4d65c8ee4 --- /dev/null +++ b/app/lib/widgets/market/orders_widget.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:threebotlogin/models/offer.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/widgets/market/order_card.dart'; + +class OrdersWidget extends StatefulWidget { + final List offers; + final bool active; + final Wallet selectedWallet; + + const OrdersWidget( + {super.key, + required this.offers, + this.active = false, + required this.selectedWallet}); + + @override + _OrdersWidgetState createState() => _OrdersWidgetState(); +} + +class _OrdersWidgetState extends State { + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(OrdersWidget oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + if (widget.offers.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 48, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + widget.active ? 'No active orders' : 'No trade history', + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (!widget.active) const SizedBox(height: 8), + if (!widget.active) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Your completed trades will appear here', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.7), + ), + ), + ), + ], + ), + ); + } + final sortedOffers = List.from(widget.offers); + sortedOffers.sort((a, b) => DateTime.parse(b.lastModifiedTime) + .compareTo(DateTime.parse(a.lastModifiedTime))); + return ListView( + padding: const EdgeInsets.only(top: 8, bottom: 80), + children: sortedOffers.map((offer) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: OrderCardWidget( + key: ValueKey(offer.id), + offer: offer, + selectedWallet: widget.selectedWallet, + active: widget.active, + ), + ); + }).toList(), + ); + } +} diff --git a/app/lib/widgets/market/wallet_selection.dart b/app/lib/widgets/market/wallet_selection.dart new file mode 100644 index 000000000..118528b7a --- /dev/null +++ b/app/lib/widgets/market/wallet_selection.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:threebotlogin/models/wallet.dart'; + +class WalletSelectionSheet extends StatelessWidget { + final List wallets; + final Wallet? selectedWallet; + final void Function(Wallet) onWalletSelected; + + const WalletSelectionSheet({ + super.key, + required this.wallets, + required this.selectedWallet, + required this.onWalletSelected, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Select Wallet', + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + const SizedBox(height: 12), + ...wallets.map((wallet) => ListTile( + title: Text(wallet.name), + trailing: wallet.name == selectedWallet?.name + ? Icon(Icons.check, + color: Theme.of(context).colorScheme.primary) + : null, + onTap: () => onWalletSelected(wallet), + )), + ], + ), + ); + } +} diff --git a/app/lib/widgets/wallets/activate_wallet.dart b/app/lib/widgets/wallets/activate_wallet.dart index 17797eaf1..34f676aa1 100644 --- a/app/lib/widgets/wallets/activate_wallet.dart +++ b/app/lib/widgets/wallets/activate_wallet.dart @@ -89,11 +89,12 @@ class _ActivateWalletWidgetState extends ConsumerState { List wallets) { return wallets .where((wallet) => - wallet != widget.wallet && double.parse(wallet.stellarBalance) > -1) + wallet != widget.wallet && + double.parse(widget.wallet.stellarBalances['TFT']!) > -1) .map((wallet) { return DropdownMenuEntry( value: wallet, - label: '${wallet.name} (${wallet.stellarBalance} TFT)', + label: '${wallet.name} (${widget.wallet.stellarBalances['TFT']!} TFT)', labelWidget: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -104,7 +105,7 @@ class _ActivateWalletWidgetState extends ConsumerState { ), ), Text( - '${wallet.stellarBalance} TFT', + '${widget.wallet.stellarBalances['TFT']!} TFT', style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -122,8 +123,8 @@ class _ActivateWalletWidgetState extends ConsumerState { }); return false; } - if (double.parse(_selectedWallet!.stellarBalance) < - (widget.wallet.stellarBalance == '-1' + if (double.parse(_selectedWallet!.stellarBalances['TFT']!) < + (widget.wallet.stellarBalances['TFT']! == '-1' ? (tftPrice * trustlineFee) : (tftPrice * activationFee))) { setState(() { @@ -310,7 +311,7 @@ class _ActivateWalletWidgetState extends ConsumerState { children: [ Center( child: Text( - widget.wallet.stellarBalance == '-1' + widget.wallet.stellarBalances['TFT']! == '-1' ? 'Add TFT Asset' : 'Activate', style: Theme.of(context) @@ -323,7 +324,7 @@ class _ActivateWalletWidgetState extends ConsumerState { ), const SizedBox(height: 20), Text( - widget.wallet.stellarBalance == '-1' + widget.wallet.stellarBalances['TFT']! == '-1' ? 'Please select a wallet to fund adding the TFT asset.' : 'Please select a wallet to fund the activation.', style: Theme.of(context).textTheme.bodyLarge!.copyWith( @@ -344,7 +345,7 @@ class _ActivateWalletWidgetState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - widget.wallet.stellarBalance == '-1' + widget.wallet.stellarBalances['TFT']! == '-1' ? 'This will consume ${tftPrice * trustlineFee} TFTs from the selected wallet.' : 'This will consume ${tftPrice * activationFee} TFTs from the selected wallet.', style: Theme.of(context) @@ -435,7 +436,7 @@ class _ActivateWalletWidgetState extends ConsumerState { ElevatedButton( onPressed: saveLoading ? null - : widget.wallet.stellarBalance == '-1' + : widget.wallet.stellarBalances['TFT']! == '-1' ? () async => await addTFTAsset() : () async => await activateWallet(), child: saveLoading @@ -445,7 +446,7 @@ class _ActivateWalletWidgetState extends ConsumerState { child: CircularProgressIndicator( strokeWidth: 2, )) - : widget.wallet.stellarBalance == '-1' + : widget.wallet.stellarBalances['TFT'] == '-1' ? const Text('Add TFT Asset') : const Text('Activate')) ], diff --git a/app/lib/widgets/wallets/bridge_confirmation.dart b/app/lib/widgets/wallets/bridge_confirmation.dart index 4085be56a..d7eb822ac 100644 --- a/app/lib/widgets/wallets/bridge_confirmation.dart +++ b/app/lib/widgets/wallets/bridge_confirmation.dart @@ -157,7 +157,7 @@ class _BridgeConfirmationWidgetState extends State { )) : Text( 'Confirm', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), textAlign: TextAlign.center, @@ -178,7 +178,7 @@ class _BridgeConfirmationWidgetState extends State { ), child: Text( 'Cancel', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), textAlign: TextAlign.center, diff --git a/app/lib/widgets/wallets/select_chain_widget.dart b/app/lib/widgets/wallets/select_chain_widget.dart index 547fb8d9a..43a247b85 100644 --- a/app/lib/widgets/wallets/select_chain_widget.dart +++ b/app/lib/widgets/wallets/select_chain_widget.dart @@ -28,7 +28,7 @@ class SelectChainWidget extends StatelessWidget { : colorScheme.secondaryContainer))), child: Text( label, - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: active ? colorScheme.onPrimaryContainer : colorScheme.onSurface), diff --git a/app/lib/widgets/wallets/send_confirmation.dart b/app/lib/widgets/wallets/send_confirmation.dart index 1eecb10a2..1fca54602 100644 --- a/app/lib/widgets/wallets/send_confirmation.dart +++ b/app/lib/widgets/wallets/send_confirmation.dart @@ -170,7 +170,7 @@ class _SendConfirmationWidgetState extends State { )) : Text( 'Confirm', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold), textAlign: TextAlign.center, @@ -191,7 +191,7 @@ class _SendConfirmationWidgetState extends State { ), child: Text( 'Cancel', - style: Theme.of(context).textTheme.titleLarge!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), textAlign: TextAlign.center, diff --git a/app/lib/widgets/wallets/wallet_card.dart b/app/lib/widgets/wallets/wallet_card.dart index 2402c8c33..c5f239fab 100644 --- a/app/lib/widgets/wallets/wallet_card.dart +++ b/app/lib/widgets/wallets/wallet_card.dart @@ -32,8 +32,8 @@ class _WalletCardWidgetState extends ConsumerState { final chainUrl = Globals().chainUrl; await initializeWallet( widget.wallet.stellarSecret, widget.wallet.tfchainSecret); - widget.wallet.stellarBalance = - await StellarService.getBalance(widget.wallet.stellarSecret); + widget.wallet.stellarBalances['TFT'] = + await StellarService.getTFTBalance(widget.wallet.stellarSecret); final tfchainBalance = await TFChainService.getBalance( chainUrl, widget.wallet.tfchainAddress); widget.wallet.tfchainBalance = @@ -68,7 +68,7 @@ class _WalletCardWidgetState extends ConsumerState { final wallet = wallets.where((w) => w.name == widget.wallet.name).firstOrNull; if (widget.wallet.type == WalletType.NATIVE && - widget.wallet.stellarBalance == '-2') { + widget.wallet.stellarBalances['TFT'] == '-2') { cardContent = [ Container( alignment: Alignment.centerRight, @@ -109,7 +109,7 @@ class _WalletCardWidgetState extends ConsumerState { ), ), const Spacer(), - if (double.parse(widget.wallet.stellarBalance) <= -1) + if (double.parse(widget.wallet.stellarBalances['TFT']!) <= -1) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( @@ -117,7 +117,7 @@ class _WalletCardWidgetState extends ConsumerState { borderRadius: BorderRadius.circular(12), ), child: Text( - widget.wallet.stellarBalance == '-1' + widget.wallet.stellarBalances['TFT'] == '-1' ? 'Asset Not Found' : 'Not Activated', style: Theme.of(context).textTheme.bodySmall!.copyWith( @@ -126,10 +126,10 @@ class _WalletCardWidgetState extends ConsumerState { ), ), ), - if (widget.wallet.stellarBalance != '-1' && - widget.wallet.stellarBalance != '-2') + if (widget.wallet.stellarBalances['TFT'] != '-1' && + widget.wallet.stellarBalances['TFT'] != '-2') Text( - '${formatAmount(widget.wallet.stellarBalance)} TFT', + '${formatAmount(widget.wallet.stellarBalances['TFT']!)} TFT', style: Theme.of(context).textTheme.bodyLarge!.copyWith( color: Theme.of(context).colorScheme.onSecondaryContainer, ), @@ -171,7 +171,7 @@ class _WalletCardWidgetState extends ConsumerState { child: InkWell( onTap: () { if (widget.wallet.type == WalletType.NATIVE && - double.parse(widget.wallet.stellarBalance) <= -1) { + double.parse(widget.wallet.stellarBalances['TFT']!) <= -1) { return; } Navigator.of(context).push(MaterialPageRoute( diff --git a/app/pubspec.lock b/app/pubspec.lock index d19ae3813..fc185e87b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -920,7 +920,7 @@ packages: source: hosted version: "4.1.0" intl: - dependency: "direct overridden" + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf @@ -1472,7 +1472,7 @@ packages: source: hosted version: "4.1.0" reflectable: - dependency: transitive + dependency: "direct main" description: name: reflectable sha256: "35ee17c3b759fa935cc7e9247445903384520fd174e0d6c142d8288e5439fd5b" @@ -1803,7 +1803,7 @@ packages: description: path: "packages/stellar_client" ref: development - resolved-ref: "4ef4d3bc2550017d987f27fd8c2264854c5cf683" + resolved-ref: "488ca66d8f19d4dfcb94708c14631c6d421a9fb4" url: "https://github.com/threefoldtech/tfgrid-sdk-dart" source: git version: "0.1.0"