diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart index aecdb79438..05c682d0ad 100644 --- a/lib/bloc/app_bloc_root.dart +++ b/lib/bloc/app_bloc_root.dart @@ -194,7 +194,7 @@ class AppBlocRoot extends StatelessWidget { komodoDefiSdk, )..add( const PriceChartStarted( - symbols: ['KMD'], + symbols: ['BTC'], period: Duration(days: 30), ), ), diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart index 08ab9cf077..fe286f2e51 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -11,6 +11,7 @@ import 'package:web_dex/bloc/cex_market_data/sdk_auth_activation_extension.dart' import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/extensions/legacy_coin_migration_extensions.dart'; part 'portfolio_growth_event.dart'; part 'portfolio_growth_state.dart'; @@ -120,10 +121,13 @@ class PortfolioGrowthBloc await emit.forEach( // computation is omitted, so null-valued events are emitted on a set // interval. - Stream.periodic(event.updateFrequency) - .asyncMap((_) async => _fetchPortfolioGrowthChart(event)), + Stream.periodic(event.updateFrequency).asyncMap((_) async { + // Update prices before fetching chart data + await portfolioGrowthRepository.updatePrices(); + return _fetchPortfolioGrowthChart(event); + }), onData: (data) => - _handlePortfolioGrowthUpdate(data, event.selectedPeriod), + _handlePortfolioGrowthUpdate(data, event.selectedPeriod, event.coins), onError: (error, stackTrace) { _log.shout('Failed to load portfolio growth', error, stackTrace); return GrowthChartLoadFailure( @@ -164,10 +168,21 @@ class PortfolioGrowthBloc return state; } + // Fetch prices before calculating total change + // This ensures we have the latest prices in the cache + await portfolioGrowthRepository.updatePrices(); + + final totalBalance = _calculateTotalBalance(coins); + final totalChange24h = _calculateTotalChange24h(coins); + final percentageChange24h = _calculatePercentageChange24h(coins); + return PortfolioGrowthChartLoadSuccess( portfolioGrowth: chart, percentageIncrease: chart.percentageIncrease, selectedPeriod: event.selectedPeriod, + totalBalance: totalBalance, + totalChange24h: totalChange24h, + percentageChange24h: percentageChange24h, ); } @@ -205,20 +220,71 @@ class PortfolioGrowthBloc PortfolioGrowthState _handlePortfolioGrowthUpdate( ChartData growthChart, Duration selectedPeriod, + List coins, ) { if (growthChart.isEmpty && state is PortfolioGrowthChartLoadSuccess) { return state; } final percentageIncrease = growthChart.percentageIncrease; - - // TODO? Include the center value in the bloc state instead of - // calculating it in the UI + final totalBalance = _calculateTotalBalance(coins); + final totalChange24h = _calculateTotalChange24h(coins); + final percentageChange24h = _calculatePercentageChange24h(coins); return PortfolioGrowthChartLoadSuccess( portfolioGrowth: growthChart, percentageIncrease: percentageIncrease, selectedPeriod: selectedPeriod, + totalBalance: totalBalance, + totalChange24h: totalChange24h, + percentageChange24h: percentageChange24h, + ); + } + + /// Calculate the total balance of all coins in USD + double _calculateTotalBalance(List coins) { + double total = coins.fold( + 0, + (prev, coin) => prev + (coin.lastKnownUsdBalance(sdk) ?? 0), ); + + // Return at least 0.01 if total is positive but very small + if (total > 0 && total < 0.01) { + return 0.01; + } + + return total; + } + + /// Calculate the total 24h change in USD value + double _calculateTotalChange24h(List coins) { + // Calculate the 24h change by summing the change percentage of each coin + // multiplied by its USD balance and divided by 100 (to convert percentage to decimal) + return coins.fold( + 0.0, + (sum, coin) { + // Use the price change from the CexPrice if available + final usdBalance = coin.lastKnownUsdBalance(sdk) ?? 0.0; + // Get the coin price from the repository's prices cache + final price = portfolioGrowthRepository + .getCachedPrice(coin.id.symbol.configSymbol.toUpperCase()); + final change24h = price?.change24h ?? 0.0; + return sum + (change24h * usdBalance / 100); + }, + ); + } + + /// Calculate the percentage change over 24h for the entire portfolio + double _calculatePercentageChange24h(List coins) { + final double totalBalance = _calculateTotalBalance(coins); + final double totalChange = _calculateTotalChange24h(coins); + + // Avoid division by zero or very small balances + if (totalBalance <= 0.01) { + return 0.0; + } + + // Return the percentage change + return (totalChange / totalBalance) * 100; } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart index d369b95dab..63398b5f2d 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -71,9 +71,12 @@ class PortfolioGrowthRepository { /// The graph cache provider to store the portfolio growth graph data. final PersistenceProvider _graphCache; - final CoinsRepo _coinsRepository; + /// The SDK needed for connecting to blockchain nodes final KomodoDefiSdk _sdk; + /// The coins repository for detailed coin info + final CoinsRepo _coinsRepository; + final _log = Logger('PortfolioGrowthRepository'); static Future ensureInitialized() async { @@ -514,4 +517,38 @@ class PortfolioGrowthRepository { } Future clearCache() => _graphCache.deleteAll(); + + /// Calculate the total 24h change in USD value for a list of coins + /// + /// This method fetches the current prices for all coins and calculates + /// the 24h change by multiplying each coin's percentage change by its USD balance + Future calculateTotalChange24h(List coins) async { + // Fetch current prices including 24h change data + final prices = await _coinsRepository.fetchCurrentPrices() ?? {}; + + // Calculate the 24h change by summing the change percentage of each coin + // multiplied by its USD balance and divided by 100 (to convert percentage to decimal) + double totalChange = 0.0; + for (final coin in coins) { + final price = prices[coin.id.symbol.configSymbol.toUpperCase()]; + final change24h = price?.change24h ?? 0.0; + final usdBalance = coin.lastKnownUsdBalance(_sdk) ?? 0.0; + totalChange += (change24h * usdBalance / 100); + } + return totalChange; + } + + /// Get the cached price for a given coin symbol + /// + /// This is used to avoid fetching prices for every calculation + CexPrice? getCachedPrice(String symbol) { + return _coinsRepository.getCachedPrice(symbol); + } + + /// Update prices for all coins by fetching from market data + /// + /// This method ensures we have up-to-date price data before calculations + Future updatePrices() async { + await _coinsRepository.fetchCurrentPrices(); + } } diff --git a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart index bd53543cb4..a964bbca16 100644 --- a/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart @@ -19,16 +19,25 @@ final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { required this.portfolioGrowth, required this.percentageIncrease, required super.selectedPeriod, + required this.totalBalance, + required this.totalChange24h, + required this.percentageChange24h, }); final ChartData portfolioGrowth; final double percentageIncrease; + final double totalBalance; + final double totalChange24h; + final double percentageChange24h; @override List get props => [ portfolioGrowth, percentageIncrease, selectedPeriod, + totalBalance, + totalChange24h, + percentageChange24h, ]; } diff --git a/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart b/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart index f2ebc0b077..d502b9d18a 100644 --- a/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart +++ b/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart @@ -23,7 +23,8 @@ enum PriceChartPeriod { } } - String get intervalString { + // TODO: Localize this + String get formatted { switch (this) { case PriceChartPeriod.oneHour: return '1h'; diff --git a/lib/bloc/cex_market_data/price_chart/models/time_period.dart b/lib/bloc/cex_market_data/price_chart/models/time_period.dart index 1dc84f0d64..2dc7500435 100644 --- a/lib/bloc/cex_market_data/price_chart/models/time_period.dart +++ b/lib/bloc/cex_market_data/price_chart/models/time_period.dart @@ -23,6 +23,9 @@ enum TimePeriod { } } + // TODO: Localize + String formatted() => name; + Duration get duration { switch (this) { case TimePeriod.oneHour: diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index b25561791e..7ea0ce8a77 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -658,4 +658,11 @@ class CoinsRepo { result: withdrawDetails, ); } + + /// Get a cached price for a given coin symbol + /// + /// This returns the price from the cache without fetching new data + CexPrice? getCachedPrice(String symbol) { + return _pricesCache[symbol]; + } } diff --git a/lib/model/cex_price.dart b/lib/model/cex_price.dart index ee2e6f9540..be9f247628 100644 --- a/lib/model/cex_price.dart +++ b/lib/model/cex_price.dart @@ -1,12 +1,7 @@ -import 'package:equatable/equatable.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart' + as sdk_types; -enum CexDataProvider { - binance, - coingecko, - coinpaprika, - nomics, - unknown, -} +typedef CexDataProvider = sdk_types.CexDataProvider; CexDataProvider cexDataProvider(String string) { return CexDataProvider.values.firstWhere( @@ -14,69 +9,4 @@ CexDataProvider cexDataProvider(String string) { orElse: () => CexDataProvider.unknown); } -class CexPrice extends Equatable { - const CexPrice({ - required this.ticker, - required this.price, - this.lastUpdated, - this.priceProvider, - this.change24h, - this.changeProvider, - this.volume24h, - this.volumeProvider, - }); - - final String ticker; - final double price; - final DateTime? lastUpdated; - final CexDataProvider? priceProvider; - final double? volume24h; - final CexDataProvider? volumeProvider; - final double? change24h; - final CexDataProvider? changeProvider; - - @override - String toString() { - return 'CexPrice(ticker: $ticker, price: $price)'; - } - - factory CexPrice.fromJson(Map json) { - return CexPrice( - ticker: json['ticker'] as String, - price: (json['price'] as num).toDouble(), - lastUpdated: json['lastUpdated'] == null - ? null - : DateTime.parse(json['lastUpdated'] as String), - priceProvider: cexDataProvider(json['priceProvider'] as String), - volume24h: (json['volume24h'] as num?)?.toDouble(), - volumeProvider: cexDataProvider(json['volumeProvider'] as String), - change24h: (json['change24h'] as num?)?.toDouble(), - changeProvider: cexDataProvider(json['changeProvider'] as String), - ); - } - - Map toJson() { - return { - 'ticker': ticker, - 'price': price, - 'lastUpdated': lastUpdated?.toIso8601String(), - 'priceProvider': priceProvider?.toString(), - 'volume24h': volume24h, - 'volumeProvider': volumeProvider?.toString(), - 'change24h': change24h, - 'changeProvider': changeProvider?.toString(), - }; - } - - @override - List get props => [ - ticker, - price, - lastUpdated, - priceProvider, - volume24h, - volumeProvider, - change24h, - changeProvider, - ]; -} +typedef CexPrice = sdk_types.CexPrice; diff --git a/lib/shared/widgets/coin_balance.dart b/lib/shared/widgets/coin_balance.dart index cf97be4b24..1376b171d9 100644 --- a/lib/shared/widgets/coin_balance.dart +++ b/lib/shared/widgets/coin_balance.dart @@ -50,7 +50,7 @@ class CoinBalance extends StatelessWidget { ), child: Row( children: [ - Text('(', style: balanceStyle), + Text(' (', style: balanceStyle), CoinFiatBalance( coin, isAutoScrollEnabled: true, diff --git a/lib/views/wallet/wallet_page/common/asset_list_item.dart b/lib/views/wallet/wallet_page/common/asset_list_item.dart index 1a5a4c9d7d..8d64c02996 100644 --- a/lib/views/wallet/wallet_page/common/asset_list_item.dart +++ b/lib/views/wallet/wallet_page/common/asset_list_item.dart @@ -14,12 +14,14 @@ class AssetListItem extends StatelessWidget { required this.backgroundColor, required this.onTap, this.isActivating = false, + this.priceChangePercentage24h, }); final AssetId assetId; final Color backgroundColor; final void Function(AssetId) onTap; final bool isActivating; + final double? priceChangePercentage24h; @override Widget build(BuildContext context) { diff --git a/lib/views/wallet/wallet_page/common/assets_list.dart b/lib/views/wallet/wallet_page/common/assets_list.dart index 3d6b394285..ba3c56585e 100644 --- a/lib/views/wallet/wallet_page/common/assets_list.dart +++ b/lib/views/wallet/wallet_page/common/assets_list.dart @@ -47,6 +47,7 @@ class AssetsList extends StatelessWidget { assetId: asset, backgroundColor: backgroundColor, onTap: onAssetItemTap, + priceChangePercentage24h: priceChangePercentages[asset.id], ); }, childCount: filteredAssets.length, diff --git a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart index b195288f9d..54f892848e 100644 --- a/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart +++ b/lib/views/wallet/wallet_page/common/expandable_coin_list_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; @@ -18,6 +19,9 @@ import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; import 'package:web_dex/views/wallet/common/wallet_helper.dart'; import 'package:get_it/get_it.dart'; +/// Widget for showing an authenticated user's balance and anddresses for a +/// given coin +// TODO: Refactor to `AssetId` and migrate to the SDK UI library. class ExpandableCoinListItem extends StatefulWidget { final Coin coin; final AssetPubkeys? pubkeys; @@ -73,12 +77,17 @@ class _ExpandableCoinListItemState extends State { ..sort((a, b) => b.balance.spendable.compareTo(a.balance.spendable))) : null; + final horizontalPadding = isMobile ? 16.0 : 18.0; + final verticalPadding = isMobile ? 12.0 : 12.0; + return CollapsibleCard( key: PageStorageKey('coin_${widget.coin.abbr}'), borderRadius: BorderRadius.circular(12), - headerPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + headerPadding: EdgeInsets.symmetric( + horizontal: horizontalPadding, vertical: verticalPadding), onTap: widget.onTap, - childrenMargin: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + childrenMargin: EdgeInsets.symmetric( + horizontal: horizontalPadding, vertical: verticalPadding), childrenDecoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(12), @@ -109,6 +118,79 @@ class _ExpandableCoinListItemState extends State { Widget _buildTitle(BuildContext context) { final theme = Theme.of(context); + if (isMobile) { + return _buildMobileTitle(context, theme); + } else { + return _buildDesktopTitle(context, theme); + } + } + + Widget _buildMobileTitle(BuildContext context, ThemeData theme) { + return Container( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top row: Asset info and additional info (like BEP-20) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: CoinItem(coin: widget.coin, size: CoinItemSize.large), + ), + ], + ), + const SizedBox(height: 8), + // Bottom row: Balance and 24h change + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Balance', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + CoinBalance(coin: widget.coin), + ], + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '24h %', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + TrendPercentageText( + percentage: getTotal24Change( + [widget.coin], + context.sdk, + ) ?? + 0, + iconSize: 16, + spacing: 4, + textStyle: theme.textTheme.bodyMedium, + ), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildDesktopTitle(BuildContext context, ThemeData theme) { return Container( alignment: Alignment.centerLeft, child: Row( @@ -163,62 +245,62 @@ class _AddressRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return ListTile( - onTap: onTap, - contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - leading: CircleAvatar( - radius: 16, - backgroundColor: theme.colorScheme.surfaceContainerHigh, - child: const Icon(Icons.person_outline), - ), - title: Row( - children: [ - Text( - pubkey.addressShort, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: IconButton( - iconSize: 16, - icon: const Icon(Icons.copy), - onPressed: onCopy, - visualDensity: VisualDensity.compact, - // constraints: const BoxConstraints( - // minWidth: 32, - // minHeight: 32, - // ), + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ListTile( + onTap: onTap, + contentPadding: + const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + leading: CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.surfaceContainerHigh, + child: const Icon(Icons.person_outline), + ), + title: Row( + children: [ + Text( + pubkey.addressShort, + style: theme.textTheme.bodyMedium, ), - ), - if (isSwapAddress && !kIsWalletOnly) ...[ const SizedBox(width: 8), - const Chip( - label: Text( - 'Swap', - // style: theme.textTheme.labelSmall, + Material( + color: Colors.transparent, + child: IconButton( + iconSize: 16, + icon: const Icon(Icons.copy), + onPressed: onCopy, + visualDensity: VisualDensity.compact, ), - // backgroundColor: theme.colorScheme.primaryContainer, ), + if (isSwapAddress && !kIsWalletOnly) ...[ + const SizedBox(width: 8), + const Chip( + label: Text( + 'Swap', + // style: theme.textTheme.labelSmall, + ), + // backgroundColor: theme.colorScheme.primaryContainer, + ), + ], ], - ], - ), - trailing: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${doubleToString(pubkey.balance.spendable.toDouble())} ${coin.abbr}', - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + ), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${doubleToString(pubkey.balance.spendable.toDouble())} ${coin.abbr}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), ), - ), - CoinFiatBalance( - coin, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + CoinFiatBalance( + coin, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart index e155ed0d98..3d97275d31 100644 --- a/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart +++ b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart @@ -56,30 +56,29 @@ class ActiveCoinsList extends StatelessWidget { sorted = removeTestCoins(sorted); } - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final coin = sorted[index]; + return SliverList.builder( + itemCount: sorted.length, + itemBuilder: (context, index) { + final coin = sorted[index]; - // Fetch pubkeys if not already loaded - if (!state.pubkeys.containsKey(coin.abbr)) { - context.read().add(CoinsPubkeysRequested(coin.abbr)); - } + // Fetch pubkeys if not already loaded + if (!state.pubkeys.containsKey(coin.abbr)) { + // TODO: Investigate if this is causing performance issues + context.read().add(CoinsPubkeysRequested(coin.abbr)); + } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ExpandableCoinListItem( - // Changed from ExpandableCoinListItem - key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), - coin: coin, - pubkeys: state.pubkeys[coin.abbr], - isSelected: false, - onTap: () => onCoinItemTap(coin), - ), - ); - }, - childCount: sorted.length, - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ExpandableCoinListItem( + // Changed from ExpandableCoinListItem + key: Key('coin-list-item-${coin.abbr.toLowerCase()}'), + coin: coin, + pubkeys: state.pubkeys[coin.abbr], + isSelected: false, + onTap: () => onCoinItemTap(coin), + ), + ); + }, ); }, ); diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index e6e8ca52e6..5d0aa79b08 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -29,9 +29,9 @@ import 'package:web_dex/views/common/page_header/page_header.dart'; import 'package:web_dex/views/common/pages/page_layout.dart'; import 'package:web_dex/views/dex/dex_helpers.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; -import 'package:web_dex/bloc/analytics/analytics_event.dart'; import 'package:web_dex/analytics/events.dart'; -import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/animated_portfolio_charts.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; import 'package:web_dex/views/wallet/wallet_page/common/assets_list.dart'; import 'package:web_dex/views/wallet/wallet_page/wallet_main/active_coins_list.dart'; @@ -70,7 +70,7 @@ class _WalletMainState extends State _loadWalletData(authBloc.state.currentUser!.wallet.id).ignore(); } - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); } @override @@ -109,51 +109,143 @@ class _WalletMainState extends State header: isMobile ? PageHeader(title: LocaleKeys.wallet.tr()) : null, content: Expanded( - child: CustomScrollView( - key: const Key('wallet-page-scroll-view'), - controller: _scrollController, - slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - if (authStateMode == AuthorizeMode.logIn) ...[ - WalletOverview( - onPortfolioGrowthPressed: () => - _tabController.animateTo(0), - onPortfolioProfitLossPressed: () => - _tabController.animateTo(1), + child: Column( + children: [ + if (authStateMode == AuthorizeMode.logIn) ...[ + WalletOverview( + key: const Key('wallet-overview'), + onPortfolioGrowthPressed: () => + _tabController.animateTo(1), + onPortfolioProfitLossPressed: () => + _tabController.animateTo(2), + onAssetsPressed: () => _tabController.animateTo(0), + ), + const Gap(8), + // Tab structure with charts and coins list using NestedScrollView + Expanded( + child: NestedScrollView( + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: _tabController, + tabs: [ + Tab(text: LocaleKeys.assets.tr()), + Tab( + text: LocaleKeys.portfolioGrowth + .tr()), + Tab( + text: + LocaleKeys.profitAndLoss.tr()), + ], + ), + ), + ), + ), + ]; + }, + body: TabBarView( + // // Clamp to horizontal scrolling + // physics: const NeverScrollableScrollPhysics(), + controller: _tabController, + children: [ + // Coins List Tab + CustomScrollView( + // physics: const ClampingScrollPhysics(), + key: const Key('wallet-page-scroll-view'), + controller: _scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SliverSearchBarDelegate( + withBalance: _showCoinWithBalance, + onSearchChange: _onSearchChange, + onWithBalanceChange: + _onShowCoinsWithBalanceClick, + mode: authStateMode, + ), + ), + SliverToBoxAdapter( + child: SizedBox(height: 8), + ), + CoinListView( + mode: authStateMode, + searchPhrase: _searchKey, + withBalance: _showCoinWithBalance, + onActiveCoinItemTap: _onActiveCoinItemTap, + onAssetItemTap: _onAssetItemTap, + ), + ], + ), + // Portfolio Growth Chart Tab + SingleChildScrollView( + child: Container( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart( + initialCoins: walletCoinsFiltered, + ), + ), + ), + // Profit/Loss Chart Tab + SingleChildScrollView( + child: Container( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart( + initialCoins: walletCoinsFiltered, + ), + ), + ), + ], + ), + ), + ), + ] else ...[ + // For non-logged in users, show the price chart and coins list + const SizedBox( + width: double.infinity, + height: 340, + child: PriceChartPage(key: Key('price-chart')), + ), + const Gap(8), + Expanded( + child: CustomScrollView( + key: const Key('wallet-page-scroll-view'), + controller: _scrollController, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _SliverSearchBarDelegate( + withBalance: _showCoinWithBalance, + onSearchChange: _onSearchChange, + onWithBalanceChange: + _onShowCoinsWithBalanceClick, + mode: authStateMode, + ), ), - const Gap(8), - ], - if (authStateMode != AuthorizeMode.logIn) - const SizedBox( - width: double.infinity, - height: 340, - child: PriceChartPage(key: Key('price-chart')), - ) - else - AnimatedPortfolioCharts( - key: const Key('animated_portfolio_charts'), - tabController: _tabController, - walletCoinsFiltered: walletCoinsFiltered, + SliverToBoxAdapter( + child: SizedBox(height: 8), ), - const Gap(8), - ], - ), - ), - SliverPersistentHeader( - pinned: true, - delegate: _SliverSearchBarDelegate( - withBalance: _showCoinWithBalance, - onSearchChange: _onSearchChange, - onWithBalanceChange: _onShowCoinsWithBalanceClick, - mode: authStateMode, + CoinListView( + mode: authStateMode, + searchPhrase: _searchKey, + withBalance: _showCoinWithBalance, + onActiveCoinItemTap: _onActiveCoinItemTap, + onAssetItemTap: _onAssetItemTap, + ), + ], + ), ), - ), - SliverToBoxAdapter( - child: SizedBox(height: 8), - ), - _buildCoinList(authStateMode), + ], ], ), ), @@ -223,39 +315,6 @@ class _WalletMainState extends State assetOverviewBloc.add(const AssetOverviewClearRequested()); } - Widget _buildCoinList(AuthorizeMode mode) { - switch (mode) { - case AuthorizeMode.logIn: - return ActiveCoinsList( - searchPhrase: _searchKey, - withBalance: _showCoinWithBalance, - onCoinItemTap: _onActiveCoinItemTap, - ); - case AuthorizeMode.hiddenLogin: - case AuthorizeMode.noLogin: - return AssetsList( - useGroupedView: true, - assets: context - .read() - .state - .coins - .values - .map((coin) => coin.assetId) - .toList(), - withBalance: false, - searchPhrase: _searchKey, - onAssetItemTap: (assetId) => _onAssetItemTap( - context - .read() - .state - .coins - .values - .firstWhere((coin) => coin.assetId == assetId), - ), - ); - } - } - void _onShowCoinsWithBalanceClick(bool? value) { setState(() { _showCoinWithBalance = value ?? false; @@ -273,11 +332,6 @@ class _WalletMainState extends State routingState.walletState.action = coinsManagerRouteAction.none; } - void _onCoinItemTap(Coin coin) { - _popupDispatcher = _createPopupDispatcher(); - _popupDispatcher!.show(); - } - void _onAssetItemTap(Coin coin) { _popupDispatcher = _createPopupDispatcher(); _popupDispatcher!.show(); @@ -321,6 +375,57 @@ class _WalletMainState extends State } } +class CoinListView extends StatelessWidget { + const CoinListView({ + super.key, + required this.mode, + required this.searchPhrase, + required this.withBalance, + required this.onActiveCoinItemTap, + required this.onAssetItemTap, + }); + + final AuthorizeMode mode; + final String searchPhrase; + final bool withBalance; + final Function(Coin) onActiveCoinItemTap; + final Function(Coin) onAssetItemTap; + + @override + Widget build(BuildContext context) { + switch (mode) { + case AuthorizeMode.logIn: + return ActiveCoinsList( + searchPhrase: searchPhrase, + withBalance: withBalance, + onCoinItemTap: onActiveCoinItemTap, + ); + case AuthorizeMode.hiddenLogin: + case AuthorizeMode.noLogin: + return AssetsList( + useGroupedView: true, + assets: context + .read() + .state + .coins + .values + .map((coin) => coin.assetId) + .toList(), + withBalance: false, + searchPhrase: searchPhrase, + onAssetItemTap: (assetId) => onAssetItemTap( + context + .read() + .state + .coins + .values + .firstWhere((coin) => coin.assetId == assetId), + ), + ); + } + } +} + class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { _SliverSearchBarDelegate({ required this.withBalance, @@ -336,7 +441,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { @override final double minExtent = 132; @override - final double maxExtent = 132; + final double maxExtent = 155; @override Widget build( diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart index e97fcd975c..3e0dcb1469 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart @@ -29,57 +29,56 @@ class WalletManageSection extends StatelessWidget { @override Widget build(BuildContext context) { - return isMobile - ? _buildMobileSection(context) - : _buildDesktopSection(context); + return Card( + clipBehavior: Clip.antiAlias, + color: Theme.of(context).colorScheme.surface, + margin: const EdgeInsets.all(0), + elevation: pinned ? 2 : 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: isMobile + ? _buildMobileSection(context) + : _buildDesktopSection(context)); } bool get isAuthenticated => mode == AuthorizeMode.logIn; Widget _buildDesktopSection(BuildContext context) { final ThemeData theme = Theme.of(context); - return Card( - clipBehavior: Clip.antiAlias, - color: theme.colorScheme.surface, - margin: const EdgeInsets.all(0), - elevation: pinned ? 2 : 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Flexible( - child: Container( - alignment: Alignment.centerLeft, - constraints: const BoxConstraints(maxWidth: 300), - child: WalletManagerSearchField(onChange: onSearchChange), - ), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Container( + alignment: Alignment.centerLeft, + constraints: const BoxConstraints(maxWidth: 300), + child: WalletManagerSearchField(onChange: onSearchChange), + ), + ), + if (isAuthenticated ) ...[ + Spacer(), + CoinsWithBalanceCheckbox( + withBalance: withBalance, + onWithBalanceChange: onWithBalanceChange, + ), + SizedBox(width: 24), + UiPrimaryButton( + buttonKey: const Key('add-assets-button'), + onPressed: () => _onAddAssetsPress(context), + text: LocaleKeys.addAssets.tr(), + height: 36, + width: 147, + borderRadius: 10, + textStyle: theme.textTheme.bodySmall, ), - if (isAuthenticated) ...[ - Spacer(), - CoinsWithBalanceCheckbox( - withBalance: withBalance, - onWithBalanceChange: onWithBalanceChange, - ), - SizedBox(width: 24), - UiPrimaryButton( - buttonKey: const Key('add-assets-button'), - onPressed: () => _onAddAssetsPress(context), - text: LocaleKeys.addAssets.tr(), - height: 36, - width: 147, - borderRadius: 10, - textStyle: theme.textTheme.bodySmall, - ), - ], ], - ), - Spacer(), - CoinsListHeader(isAuth: mode == AuthorizeMode.logIn), - ], - ), + ], + ), + Spacer(), + CoinsListHeader(isAuth: mode == AuthorizeMode.logIn), + ], ), ); } @@ -95,7 +94,7 @@ class WalletManageSection extends StatelessWidget { Row( children: [ Text( - 'Currency', + 'Portfolio', style: theme.textTheme.titleLarge, ), Spacer(), @@ -103,7 +102,7 @@ class WalletManageSection extends StatelessWidget { UiPrimaryButton( buttonKey: const Key('asset-management-button'), onPressed: () => _onAddAssetsPress(context), - text: 'Asset management', + text: 'Add assets', height: 36, width: 147, borderRadius: 10, @@ -130,6 +129,7 @@ class WalletManageSection extends StatelessWidget { ), ], ), + Spacer(), ], ), ); diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart index e68805f747..2ed67e59cc 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart @@ -4,21 +4,32 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui/komodo_ui.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/models/time_period.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; import 'package:web_dex/analytics/events/portfolio_events.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +// TODO(@takenagain): Please clean up the widget structure and bloc usage for +// the wallet overview. It may be better to split this into a separate bloc +// instead of the changes we've made to the existing PortfolioGrowthBloc since +// that bloc is primarily focused on chart data. class WalletOverview extends StatefulWidget { const WalletOverview({ super.key, this.onPortfolioGrowthPressed, this.onPortfolioProfitLossPressed, + this.onAssetsPressed, }); final VoidCallback? onPortfolioGrowthPressed; final VoidCallback? onPortfolioProfitLossPressed; + final VoidCallback? onAssetsPressed; @override State createState() => _WalletOverviewState(); @@ -32,15 +43,24 @@ class _WalletOverviewState extends State { return BlocBuilder( builder: (context, state) { if (state.coins.isEmpty) return _buildSpinner(); - final portfolioAssetsOverviewBloc = context.watch(); final int assetCount = state.walletCoins.length; + + // Get the portfolio growth bloc to access balance and 24h change + final portfolioGrowthBloc = context.watch(); + final portfolioGrowthState = portfolioGrowthBloc.state; + + // Get total balance from the PortfolioGrowthBloc if available, otherwise calculate + final double totalBalance = + portfolioGrowthState is PortfolioGrowthChartLoadSuccess + ? portfolioGrowthState.totalBalance + : _getTotalBalance(state.walletCoins.values, context); + final stateWithData = portfolioAssetsOverviewBloc.state is PortfolioAssetsOverviewLoadSuccess ? portfolioAssetsOverviewBloc.state as PortfolioAssetsOverviewLoadSuccess : null; - if (!_logged && stateWithData != null) { context.read().logEvent( PortfolioViewedEventData( @@ -51,52 +71,94 @@ class _WalletOverviewState extends State { _logged = true; } - return Wrap( - runSpacing: 16, - children: [ - FractionallySizedBox( - widthFactor: isMobile ? 1 : 0.5, - child: StatisticCard( - key: const Key('overview-total-balance'), - caption: Text(LocaleKeys.allTimeInvestment.tr()), - value: stateWithData?.totalInvestment.value ?? 0, - actionIcon: const Icon(CustomIcons.fiatIconCircle), - onPressed: widget.onPortfolioGrowthPressed, - footer: Container( - height: 28, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - borderRadius: BorderRadius.circular(28), + // Create the statistic cards + final List statisticCards = [ + StatisticCard( + key: const Key('overview-current-value'), + caption: Text(LocaleKeys.yourBalance.tr()), + value: totalBalance, + actionIcon: const Icon(Icons.copy), + onPressed: () { + final formattedValue = + NumberFormat.currency(symbol: '\$').format(totalBalance); + copyToClipBoard(context, formattedValue); + }, + footer: BlocBuilder( + builder: (context, state) { + final double totalChange = + state is PortfolioGrowthChartLoadSuccess + ? state.percentageChange24h + : 0.0; + + return Chip( + visualDensity: const VisualDensity(vertical: -4), + label: TrendPercentageText( + percentage: totalChange, + suffix: Text(TimePeriod.oneDay.formatted()), + precision: 2, ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.pie_chart, - size: 16, - ), - const SizedBox(width: 4), - Text('$assetCount ${LocaleKeys.assets.tr()}'), - ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ), + ); + }, + ), + ), + StatisticCard( + key: const Key('overview-all-time-investment'), + caption: Text(LocaleKeys.allTimeInvestment.tr()), + value: stateWithData?.totalInvestment.value ?? 0, + actionIcon: const Icon(CustomIcons.fiatIconCircle), + onPressed: widget.onPortfolioGrowthPressed, + footer: ActionChip( + avatar: Icon( + Icons.pie_chart, + ), + onPressed: widget.onAssetsPressed, + visualDensity: const VisualDensity(vertical: -4), + label: Text( + LocaleKeys.assetNumber.plural(assetCount), + style: Theme.of(context).textTheme.bodyLarge, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), ), - FractionallySizedBox( - widthFactor: isMobile ? 1 : 0.5, - child: StatisticCard( - caption: Text(LocaleKeys.allTimeProfit.tr()), - value: stateWithData?.profitAmount.value ?? 0, - footer: TrendPercentageText( - percentage: stateWithData?.profitIncreasePercentage ?? 0, - ), - actionIcon: const Icon(Icons.trending_up), - onPressed: widget.onPortfolioProfitLossPressed, + ), + StatisticCard( + key: const Key('overview-all-time-profit'), + caption: Text(LocaleKeys.allTimeProfit.tr()), + value: stateWithData?.profitAmount.value ?? 0, + footer: Chip( + visualDensity: const VisualDensity(vertical: -4), + label: TrendPercentageText( + percentage: stateWithData?.profitIncreasePercentage ?? 0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), ), - ], - ); + actionIcon: const Icon(Icons.trending_up), + onPressed: widget.onPortfolioProfitLossPressed, + ), + ]; + + // Use carousel for mobile and wrap for desktop + // TODO: `Wrap` is currently redundant. Enforce a minimum width for + // the cards instead. + if (isMobile) { + return StatisticsCarousel(cards: statisticCards); + } else { + return Wrap( + runSpacing: 16, + children: statisticCards.map((card) { + return FractionallySizedBox( + widthFactor: 1 / 3, + child: card, + ); + }).toList(), + ); + } }, ); } @@ -112,4 +174,98 @@ class _WalletOverviewState extends State { ], ); } + + // TODO: Migrate these values to a new/existing bloc e.g. PortfolioGrowthBloc + double _getTotalBalance(Iterable coins, BuildContext context) { + double total = coins.fold( + 0, (prev, coin) => prev + (coin.usdBalance(context.sdk) ?? 0)); + + if (total > 0.01) { + return total; + } + + return total != 0 ? 0.01 : 0; + } +} + +/// A carousel widget that displays statistics cards with page indicators +class StatisticsCarousel extends StatefulWidget { + final List cards; + + const StatisticsCarousel({ + super.key, + required this.cards, + }); + + @override + State createState() => _StatisticsCarouselState(); +} + +// TODO: Refactor into a generic card carousel widget and move to `komodo_ui` +class _StatisticsCarouselState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + @override + void initState() { + super.initState(); + _pageController.addListener(() { + int next = _pageController.page?.round() ?? 0; + if (_currentPage != next) { + setState(() { + _currentPage = next; + }); + } + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 160, + child: PageView.builder( + controller: _pageController, + itemCount: widget.cards.length, + physics: const ClampingScrollPhysics(), + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: widget.cards[index], + ); + }, + ), + ), + const SizedBox(height: 16), + // Page indicators + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.cards.length, + (index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 8, + width: _currentPage == index ? 24 : 8, + decoration: BoxDecoration( + color: _currentPage == index + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ], + ); + } } diff --git a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart index 8146b205a2..5a09371c12 100644 --- a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart +++ b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart @@ -93,7 +93,7 @@ class StatisticCard extends StatelessWidget { child: IconButton.filledTonal( isSelected: false, icon: actionIcon, - iconSize: 42, + iconSize: 36, onPressed: onPressed, ), ),