diff --git a/packages/espressocash_app/lib/data/db/db.dart b/packages/espressocash_app/lib/data/db/db.dart index 3b2fb8cff6..a98304acf5 100644 --- a/packages/espressocash_app/lib/data/db/db.dart +++ b/packages/espressocash_app/lib/data/db/db.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:injectable/injectable.dart'; import '../../features/activities/models/transaction.dart'; +import '../../features/currency/models/currency.dart'; import '../../features/incoming_link_payments/data/ilp_repository.dart'; import '../../features/outgoing_direct_payments/data/repository.dart'; import '../../features/outgoing_link_payments/data/repository.dart'; @@ -24,7 +25,7 @@ class OutgoingTransferRows extends Table { Set> get primaryKey => {id}; } -const int latestVersion = 60; +const int latestVersion = 61; const _tables = [ OutgoingTransferRows, @@ -117,11 +118,9 @@ class MyDatabase extends _$MyDatabase { if (from < 51) { await m.addColumn(transactionRows, transactionRows.amount); } - if (from < 52) { await m.createTable(tokenBalanceRows); } - if (from < 53) { await m.addColumn(onRampOrderRows, onRampOrderRows.authToken); await m.addColumn(onRampOrderRows, onRampOrderRows.moreInfoUrl); @@ -171,6 +170,14 @@ class MyDatabase extends _$MyDatabase { if (from >= 39 && from < 60) { await m.addColumn(iLPRows, iLPRows.receiveAmount); } + if (from < 61) { + await m.addColumn(transactionRows, transactionRows.token); + + await customStatement( + 'UPDATE ${transactionRows.actualTableName} SET token = ?', + [Currency.usdc.token.address], + ); + } }, ); } @@ -306,6 +313,7 @@ class TransactionRows extends Table { TextColumn get encodedTx => text()(); IntColumn get status => intEnum()(); IntColumn get amount => integer().nullable()(); + TextColumn get token => text().nullable()(); @override Set> get primaryKey => {id}; diff --git a/packages/espressocash_app/lib/features/activities/data/transaction_repository.dart b/packages/espressocash_app/lib/features/activities/data/transaction_repository.dart index e14704cf82..4e7f2a03a0 100644 --- a/packages/espressocash_app/lib/features/activities/data/transaction_repository.dart +++ b/packages/espressocash_app/lib/features/activities/data/transaction_repository.dart @@ -5,15 +5,19 @@ import 'package:dfunc/dfunc.dart'; import 'package:drift/drift.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:injectable/injectable.dart'; +import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:solana/encoder.dart'; import '../../../data/db/db.dart'; +import '../../../di.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; import '../../outgoing_direct_payments/data/repository.dart'; import '../../outgoing_link_payments/data/repository.dart'; import '../../payment_request/data/repository.dart'; +import '../../tokens/data/token_repository.dart'; +import '../../tokens/token.dart'; import '../../transaction_request/service/tr_service.dart'; import '../models/activity.dart'; import '../models/transaction.dart'; @@ -32,6 +36,38 @@ class TransactionRepository { return query.map((row) => row.id).watch().map((event) => event.toIList()); } + Stream> watchByAddress(String tokenAddress) { + final query = _db.select(_db.transactionRows) + ..where((t) => t.token.equals(tokenAddress)) + ..orderBy([(t) => OrderingTerm.desc(t.created)]); + + return query.map((row) => row.id).watch().map((event) => event.toIList()); + } + + Stream>> watchGroupedByDate(String tokenAddress) { + final query = _db.select(_db.transactionRows) + ..where((t) => t.token.equals(tokenAddress)) + ..orderBy([(t) => OrderingTerm.desc(t.created)]); + + return query.watch().asyncMap((rows) async { + final grouped = >{}; + for (final row in rows) { + final model = await row.toModel(); + final created = model.created; + if (created != null) { + final date = DateFormat('yyyy-MM-dd').format(created); + grouped.update( + date, + (list) => list.add(model), + ifAbsent: () => IList([model]), + ); + } + } + + return grouped; + }); + } + Stream> watchCount(int count) { final query = _db.select(_db.transactionRows) ..limit(count) @@ -44,7 +80,9 @@ class TransactionRepository { final query = _db.select(_db.transactionRows) ..where((tbl) => tbl.id.equals(id)); - return query.watchSingle().asyncExpand((row) => _match(row.toModel())); + return query.watchSingle().asyncMap( + (row) async => row.toModel().then((value) => _match(value).first), + ); } Future saveAll( @@ -147,14 +185,26 @@ class TransactionRepository { } extension TransactionRowExt on TransactionRow { - TxCommon toModel() => TxCommon( - SignedTx.decode(encodedTx), - created: created, - status: status, - amount: amount?.let( - (it) => CryptoAmount(value: it, cryptoCurrency: Currency.usdc), + Future toModel() async { + final tokenAddress = this.token; + Token? token; + + if (tokenAddress != null) { + token = await sl().getToken(tokenAddress); + } + + return TxCommon( + SignedTx.decode(encodedTx), + created: created, + status: status, + amount: amount?.let( + (it) => CryptoAmount( + value: it, + cryptoCurrency: CryptoCurrency(token: token ?? Token.unk), ), - ); + ), + ); + } } extension on TxCommon { @@ -164,6 +214,7 @@ extension on TxCommon { encodedTx: tx.encode(), status: status, amount: amount?.value, + token: amount?.cryptoCurrency.token.address, ); } diff --git a/packages/espressocash_app/lib/features/activities/services/tx_updater.dart b/packages/espressocash_app/lib/features/activities/services/tx_updater.dart index a4b2f12a3d..88937a0490 100644 --- a/packages/espressocash_app/lib/features/activities/services/tx_updater.dart +++ b/packages/espressocash_app/lib/features/activities/services/tx_updater.dart @@ -1,19 +1,28 @@ import 'package:async/async.dart'; +import 'package:collection/collection.dart'; import 'package:dfunc/dfunc.dart'; import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; +import 'package:solana/base58.dart'; import 'package:solana/dto.dart'; import 'package:solana/encoder.dart'; import 'package:solana/solana.dart'; +import '../../../utils/chunks.dart'; import '../../accounts/auth_scope.dart'; import '../../accounts/models/ec_wallet.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; +import '../../tokens/data/token_repository.dart'; import '../../tokens/token.dart'; import '../data/transaction_repository.dart'; import '../models/transaction.dart'; +typedef TransactionUpdateResult = ({ + List txs, + bool hasGap, +}); + @Singleton(scope: authScope) class TxUpdater implements Disposable { TxUpdater(this._client, this._wallet, this._repo); @@ -24,69 +33,224 @@ class TxUpdater implements Disposable { final AsyncCache _cache = AsyncCache.ephemeral(); - Future call() => _cache.fetch( - () => tryEitherAsync((_) async { - final usdcTokenAccount = await findAssociatedTokenAddress( - owner: _wallet.publicKey, - mint: Ed25519HDPublicKey.fromBase58(Token.usdc.address), - ); + @PostConstruct() + void init() { + call(); + } - final mostRecentTxId = await _repo.mostRecentTxId(); + Future call() => _cache.fetch(_updateAllTransactions); - const fetchLimit = 50; + Future _updateAllTransactions() async { + final mostRecentTxId = await _repo.mostRecentTxId(); - final details = await _client.rpcClient.getTransactionsList( - usdcTokenAccount, - limit: fetchLimit, - until: mostRecentTxId, - encoding: Encoding.base64, - commitment: Commitment.confirmed, - ); + final tokenResult = await _updateTokensTransactions(mostRecentTxId); + final solResult = await _updateSolTransactions(mostRecentTxId); + + final uniqueSolTxs = solResult.txs + .where( + (result) => !tokenResult.txs.any((tx) => tx.tx.id == result.tx.id), + ) + .toList(); - if (details.isNotEmpty) { - final txs = details.map((it) => it.toFetched(usdcTokenAccount)); + final allTxs = uniqueSolTxs + tokenResult.txs; - final hasGap = mostRecentTxId != null && txs.length == fetchLimit; + if (allTxs.isNotEmpty) { + await _repo.saveAll( + allTxs, + clear: solResult.hasGap || tokenResult.hasGap, + ); + } + } + + Future _updateTokensTransactions( + String? mostRecentTxId, + ) async { + final tokenAccounts = await _getAllTokenAccounts(_wallet.publicKey); + final allResults = []; + + final batches = tokenAccounts.chunks(_chunkSize).toList(); + for (int i = 0; i < batches.length; i++) { + final batch = batches[i]; + final batchResults = await Future.wait( + batch.map((account) async { + final limit = account.mintAddress == Token.usdc.address + ? _usdcFetchLimit + : _tokenFetchLimit; + + final txs = await _fetchTransactions( + account.account, + account.mintAddress, + mostRecentTxId, + limit, + ); - await _repo.saveAll(txs, clear: hasGap); - } + return ( + txs: txs, + hasGap: mostRecentTxId != null && txs.length == limit, + ); }), ); + allResults.addAll(batchResults); + + if (i < batches.length - 1) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + return ( + txs: allResults.expand((r) => r.txs).toList(), + hasGap: allResults.any((r) => r.hasGap), + ); + } + + Future _updateSolTransactions( + String? mostRecentTxId, + ) async { + final txs = await _fetchTransactions( + _wallet.publicKey, + Token.sol.address, + mostRecentTxId, + _tokenFetchLimit, + ); + + return ( + txs: txs, + hasGap: mostRecentTxId != null && txs.length == _tokenFetchLimit, + ); + } + + Future> _fetchTransactions( + Ed25519HDPublicKey account, + String tokenAddress, + String? until, + int limit, + ) => + _client.rpcClient + .getTransactionsList( + account, + until: until, + limit: limit, + encoding: Encoding.base64, + commitment: Commitment.confirmed, + ) + .letAsync((transactionDetails) async { + if (transactionDetails.isEmpty) return []; + + final txs = await Future.wait( + transactionDetails.map((it) => it.toFetched(account, tokenAddress)), + ); + final filteredTxs = txs.whereNotNull().toList(); + + return filteredTxs.toSet().toList(); + }); + + Future> _getAllTokenAccounts( + Ed25519HDPublicKey owner, + ) => + _client.rpcClient + .getTokenAccountsByOwner( + owner.toBase58(), + encoding: Encoding.base64, + const TokenAccountsFilter.byProgramId(TokenProgram.programId), + ) + .letAsync( + (response) => response.value.map((account) { + final data = account.account.data as BinaryAccountData?; + if (data == null) { + throw Exception('Account info or data is null'); + } + final mintAddressBytes = data.data.sublist(0, 32); + final mintAddress = base58encode(mintAddressBytes); + + return _TokenAccountInfo( + account: Ed25519HDPublicKey.fromBase58(account.pubkey), + mintAddress: mintAddress, + ); + }).toList(), + ); + @override Future onDispose() => _repo.clear(); } extension on TransactionDetails { - TxCommon toFetched(Ed25519HDPublicKey usdcTokenAddress) { + Future toFetched( + Ed25519HDPublicKey tokenAccount, + String? tokenAddress, + ) async { final rawTx = transaction as RawTransaction; final tx = SignedTx.fromBytes(rawTx.data); - final accountIndex = - tx.compiledMessage.accountKeys.indexWhere((e) => e == usdcTokenAddress); + tx.compiledMessage.accountKeys.indexWhere((e) => e == tokenAccount); + + int? getBalanceDifference( + List? preBalances, + List? postBalances, + ) { + if (preBalances != null && postBalances != null) { + final preBalance = preBalances[accountIndex] as int; + final postBalance = postBalances[accountIndex] as int; + if (preBalance == 0 && postBalance == 0) return null; - final preTokenBalance = meta?.preTokenBalances - .where((e) => e.mint == Token.usdc.address) - .where((e) => e.accountIndex == accountIndex) - .firstOrNull; + return postBalance - preBalance; + } - final postTokenBalance = meta?.postTokenBalances - .where((e) => e.mint == Token.usdc.address) - .where((e) => e.accountIndex == accountIndex) - .firstOrNull; + return null; + } - CryptoAmount? amount; + int? getTokenBalanceDifference( + List? preBalances, + List? postBalances, + ) { + final preBalance = preBalances + ?.firstWhereOrNull( + (e) => e.mint == tokenAddress && e.accountIndex == accountIndex, + ) + ?.uiTokenAmount + .amount; - if (preTokenBalance != null && postTokenBalance != null) { - final rawAmount = int.parse(postTokenBalance.uiTokenAmount.amount) - - int.parse(preTokenBalance.uiTokenAmount.amount); + final postBalance = postBalances + ?.firstWhereOrNull( + (e) => e.mint == tokenAddress && e.accountIndex == accountIndex, + ) + ?.uiTokenAmount + .amount; - amount = CryptoAmount( - value: rawAmount, - cryptoCurrency: Currency.usdc, - ); + final preReturnValue = preBalance != null ? int.parse(preBalance) : 0; + final postReturnValue = postBalance != null ? int.parse(postBalance) : 0; + + if (preReturnValue == 0 && postReturnValue == 0) return null; + + return postReturnValue - preReturnValue; } + final rawAmount = tokenAddress == Token.sol.address + ? getBalanceDifference(meta?.preBalances, meta?.postBalances) + : getTokenBalanceDifference( + meta?.preTokenBalances, + meta?.postTokenBalances, + ); + + if (rawAmount == null || rawAmount == 0) return null; + + final amount = await rawAmount.let((amount) async { + final tokenRepository = GetIt.I(); + final cryptoCurrency = tokenAddress != null + ? tokenAddress == Token.sol.address + ? const CryptoCurrency(token: Token.sol) + : CryptoCurrency( + token: + await tokenRepository.getToken(tokenAddress) ?? Token.unk, + ) + : const CryptoCurrency(token: Token.unk); + + return CryptoAmount( + value: amount, + cryptoCurrency: cryptoCurrency, + ); + }); + return TxCommon( tx, status: @@ -97,3 +261,18 @@ extension on TransactionDetails { ); } } + +class _TokenAccountInfo { + const _TokenAccountInfo({ + required this.account, + required this.mintAddress, + }); + + final Ed25519HDPublicKey account; + final String mintAddress; +} + +const _chunkSize = 5; + +const _tokenFetchLimit = 15; +const _usdcFetchLimit = 50; diff --git a/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart index f23978ae53..f3b586801f 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/activity_tile.dart @@ -12,6 +12,7 @@ class CpActivityTile extends StatelessWidget { required this.icon, required this.status, required this.timestamp, + this.subtitle, this.incomingAmount, this.outgoingAmount, this.onTap, @@ -22,6 +23,7 @@ class CpActivityTile extends StatelessWidget { final Widget icon; final String timestamp; final CpActivityTileStatus status; + final String? subtitle; final String? incomingAmount; final String? outgoingAmount; final VoidCallback? onTap; diff --git a/packages/espressocash_app/lib/features/activities/widgets/common_tile.dart b/packages/espressocash_app/lib/features/activities/widgets/common_tile.dart index f4ce6af89d..a5d1baf803 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/common_tile.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/common_tile.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import '../../../../gen/assets.gen.dart'; import '../../../l10n/device_locale.dart'; +import '../../../l10n/l10n.dart'; import '../../../ui/web_view_screen.dart'; import '../../../utils/extensions.dart'; import '../../conversion_rates/widgets/extensions.dart'; +import '../../tokens/token.dart'; import '../../transactions/services/create_transaction_link.dart'; import '../models/transaction.dart'; import 'activity_tile.dart'; @@ -24,15 +26,21 @@ class CommonTile extends StatelessWidget { Widget build(BuildContext context) { final signature = txCommon.tx.id; + final isUnknown = txCommon.amount?.cryptoCurrency.token == Token.unk; + final isOutgoing = txCommon.amount?.let((e) => e.value.isNegative || e.value == 0) ?? false; - final amount = txCommon.amount - ?.let((e) => e.format(context.locale, maxDecimals: 2)) - .let((e) => e.replaceAll('-', '')); + + final amount = isUnknown + ? null + : txCommon.amount + ?.let((e) => e.format(context.locale, maxDecimals: 9)) + .let((e) => e.replaceAll('-', '')); return CpActivityTile( - title: signature.toShortAddress(), + title: isOutgoing ? context.l10n.sentDirectly : context.l10n.received, + subtitle: signature.toShortAddress(), status: switch (txCommon.status) { TxCommonStatus.success => CpActivityTileStatus.success, TxCommonStatus.failure => CpActivityTileStatus.failure, diff --git a/packages/espressocash_app/lib/features/activities/widgets/recent_activity.dart b/packages/espressocash_app/lib/features/activities/widgets/recent_activity.dart index 157b51262f..3abe50d5dd 100644 --- a/packages/espressocash_app/lib/features/activities/widgets/recent_activity.dart +++ b/packages/espressocash_app/lib/features/activities/widgets/recent_activity.dart @@ -8,7 +8,6 @@ import '../../../ui/colors.dart'; import '../../../ui/home_tile.dart'; import '../../../ui/theme.dart'; import '../data/transaction_repository.dart'; -import '../services/tx_updater.dart'; import 'transaction_item.dart'; class RecentActivityWidget extends StatefulWidget { @@ -34,7 +33,6 @@ class _RecentActivityWidgetState extends State { void initState() { super.initState(); _txs = sl().watchCount(_activityCount); - sl().call(); } @override diff --git a/packages/espressocash_app/lib/features/activities/widgets/recent_token_activity.dart b/packages/espressocash_app/lib/features/activities/widgets/recent_token_activity.dart new file mode 100644 index 0000000000..6756247c0b --- /dev/null +++ b/packages/espressocash_app/lib/features/activities/widgets/recent_token_activity.dart @@ -0,0 +1,218 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../di.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/home_tile.dart'; +import '../../../ui/theme.dart'; +import '../data/transaction_repository.dart'; +import '../models/transaction.dart'; +import 'common_tile.dart'; +import 'odp_tile.dart'; +import 'off_ramp_tile.dart'; +import 'olp_tile.dart'; +import 'on_ramp_tile.dart'; +import 'outgoing_dln_tile.dart'; +import 'payment_request_tile.dart'; +import 'tr_tile.dart'; + +class RecentTokenActivityWidget extends StatefulWidget { + const RecentTokenActivityWidget({ + super.key, + required this.tokenAddress, + }); + + final String tokenAddress; + + @override + State createState() => + _RecentTokenActivityWidgetState(); +} + +class _RecentTokenActivityWidgetState extends State { + late final Stream>> _groupedTxs; + + @override + void initState() { + super.initState(); + _groupedTxs = + sl().watchGroupedByDate(widget.tokenAddress); + } + + String _formatDate(String date) { + final parsedDate = DateTime.parse(date); + final now = DateTime.now(); + final yesterday = now.subtract(const Duration(days: 1)); + + return (parsedDate.year == now.year && + parsedDate.month == now.month && + parsedDate.day == now.day) + ? context.l10n.today + : (parsedDate.year == yesterday.year && + parsedDate.month == yesterday.month && + parsedDate.day == yesterday.day) + ? context.l10n.yesterday + : DateFormat('MMM d, yyyy').format(parsedDate); + } + + @override + Widget build(BuildContext context) => + StreamBuilder>>( + stream: _groupedTxs, + builder: (context, snapshot) { + final data = snapshot.data; + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + if (data == null || data.isEmpty) { + return const Center(child: _NoActivity()); + } + + final sortedDates = data.keys.toList() + ..sort((a, b) => b.compareTo(a)); + + return HomeTile( + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(height: 16), + ...sortedDates.map((date) { + final transactions = data[date]; + late final IList sortedTxs; + + if (transactions != null) { + sortedTxs = transactions.sort((a, b) { + final aCreated = a.created; + final bCreated = b.created; + + return (aCreated != null && bCreated != null) + ? bCreated.compareTo(aCreated) + : 0; + }); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 12.0, bottom: 9.0), + child: Text( + _formatDate(date), + style: dashboardSectionTitleTextStyle, + ), + ), + _Card( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: sortedTxs.length * 60, + minWidth: MediaQuery.sizeOf(context).width, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sortedTxs + .map( + (tx) => tx.map( + common: (t) => CommonTile( + key: ValueKey(t.tx.id), + txCommon: t, + showIcon: false, + ), + activity: (txActivity) => + txActivity.activity.map( + outgoingPaymentRequest: (p) => + PaymentRequestTile( + key: ValueKey(p.id), + id: p.id, + showIcon: false, + ), + outgoingDirectPayment: (p) => ODPTile( + key: ValueKey(p.id), + activity: p, + showIcon: false, + ), + outgoingLinkPayment: (p) => OLPTile( + key: ValueKey(p.id), + activity: p, + showIcon: false, + ), + onRamp: (it) => OnRampTile( + key: ValueKey(it.id), + activity: it, + showIcon: false, + ), + offRamp: (it) => OffRampTile( + key: ValueKey(it.id), + activity: it, + showIcon: false, + ), + outgoingDlnPayment: (it) => + OutgoingDlnTile( + key: ValueKey(it.id), + activity: it, + showIcon: false, + ), + transactionRequest: (it) => TrTile( + key: ValueKey(it.id), + activity: it, + showIcon: false, + ), + kyc: (it) => const SizedBox.shrink(), + ), + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 40), + ], + ); + }), + const SizedBox(height: 8), + ], + ), + ); + }, + ); +} + +class _Card extends StatelessWidget { + const _Card({required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + decoration: const ShapeDecoration( + color: CpColors.blackGreyColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(28), + ), + ), + ), + child: child, + ); +} + +class _NoActivity extends StatelessWidget { + const _NoActivity(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 72.0), + child: Text( + context.l10n.noRecentActivity, + style: const TextStyle( + color: Color(0xFF484848), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + ); +} diff --git a/packages/espressocash_app/lib/features/analytics/analytics_manager.dart b/packages/espressocash_app/lib/features/analytics/analytics_manager.dart index e8f3b62a9d..1be8d88618 100644 --- a/packages/espressocash_app/lib/features/analytics/analytics_manager.dart +++ b/packages/espressocash_app/lib/features/analytics/analytics_manager.dart @@ -74,11 +74,12 @@ class AnalyticsManager { ); void directPaymentSent({ + required String symbol, required Decimal amount, }) => _analytics.track( 'directPaymentSent', - properties: {'amount': amount.toDouble()}, + properties: {'token': symbol, 'amount': amount.toDouble()}, ); void paymentRequestLinkCreated({ diff --git a/packages/espressocash_app/lib/features/authenticated/widgets/balance_amount.dart b/packages/espressocash_app/lib/features/authenticated/widgets/balance_amount.dart index 45608bda46..19df68c532 100644 --- a/packages/espressocash_app/lib/features/authenticated/widgets/balance_amount.dart +++ b/packages/espressocash_app/lib/features/authenticated/widgets/balance_amount.dart @@ -8,6 +8,7 @@ import '../../conversion_rates/services/token_fiat_balance_service.dart'; import '../../conversion_rates/widgets/extensions.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; +import '../../token_details/screens/token_details_screen.dart'; import '../../tokens/token.dart'; import '../../tokens/widgets/token_icon.dart'; @@ -26,24 +27,27 @@ class BalanceAmount extends StatelessWidget { roundInteger: amount.isZero, ); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FittedBox( - child: Text( - formattedAmount, - style: const TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: Colors.white, - letterSpacing: -1, + return GestureDetector( + onTap: () => TokenDetailsScreen.push(context, token: Token.usdc), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FittedBox( + child: Text( + formattedAmount, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: -1, + ), ), - ), - ).let((it) => amount.isZero ? it : Flexible(child: it)), - const SizedBox(width: 8), - const TokenIcon(token: Token.usdc, size: 30), - ], + ).let((it) => amount.isZero ? it : Flexible(child: it)), + const SizedBox(width: 8), + const TokenIcon(token: Token.usdc, size: 30), + ], + ), ); }, ); diff --git a/packages/espressocash_app/lib/features/authenticated/widgets/portfolio_widget.dart b/packages/espressocash_app/lib/features/authenticated/widgets/portfolio_widget.dart index 880feda31a..64b85b9158 100644 --- a/packages/espressocash_app/lib/features/authenticated/widgets/portfolio_widget.dart +++ b/packages/espressocash_app/lib/features/authenticated/widgets/portfolio_widget.dart @@ -11,6 +11,7 @@ import '../../../ui/value_stream_builder.dart'; import '../../conversion_rates/services/token_fiat_balance_service.dart'; import '../../conversion_rates/widgets/extensions.dart'; import '../../currency/models/amount.dart'; +import '../../token_details/screens/token_details_screen.dart'; import '../../tokens/widgets/token_icon.dart'; class PortfolioWidget extends StatefulWidget { @@ -158,6 +159,8 @@ class _TokenItem extends StatelessWidget { borderRadius: BorderRadius.circular(_iconSize / 2), child: TokenIcon(token: cryptoAmount.token, size: _iconSize), ), + onTap: () => + TokenDetailsScreen.push(context, token: cryptoAmount.token), title: Text( cryptoAmount.token.name, style: const TextStyle( diff --git a/packages/espressocash_app/lib/features/conversion_rates/services/token_fiat_balance_service.dart b/packages/espressocash_app/lib/features/conversion_rates/services/token_fiat_balance_service.dart index 3732cea854..88f49aa377 100644 --- a/packages/espressocash_app/lib/features/conversion_rates/services/token_fiat_balance_service.dart +++ b/packages/espressocash_app/lib/features/conversion_rates/services/token_fiat_balance_service.dart @@ -92,6 +92,14 @@ class TokenFiatBalanceService { ) .distinct(); + Stream readInvestmentBalance(Token token) => + _balancesRepository.watch(token).flatMap( + (amount) => Rx.combineLatest( + [watch(amount.token).map((fiat) => (amount, fiat))], + (values) => values.map((e) => (e.$1, e.$2 ?? _zeroFiat)).first, + ), + ); + void _logTotalCryptoBalance(Amount total) => _analyticsManager.setTotalInvestmentsBalance(total.decimal); } diff --git a/packages/espressocash_app/lib/features/conversion_rates/widgets/amount_with_equivalent.dart b/packages/espressocash_app/lib/features/conversion_rates/widgets/amount_with_equivalent.dart index 3d679ed97c..4f67b90336 100644 --- a/packages/espressocash_app/lib/features/conversion_rates/widgets/amount_with_equivalent.dart +++ b/packages/espressocash_app/lib/features/conversion_rates/widgets/amount_with_equivalent.dart @@ -1,6 +1,7 @@ import 'package:dfunc/dfunc.dart'; import 'package:flutter/material.dart'; +import '../../../di.dart'; import '../../../l10n/decimal_separator.dart'; import '../../../l10n/device_locale.dart'; import '../../../l10n/l10n.dart'; @@ -13,6 +14,7 @@ import '../../../ui/usdc_info.dart'; import '../../currency/models/amount.dart'; import '../../currency/models/currency.dart'; import '../../tokens/token.dart'; +import '../data/repository.dart'; import '../services/amount_ext.dart'; import 'extensions.dart'; @@ -25,6 +27,7 @@ class AmountWithEquivalent extends StatelessWidget { this.shakeKey, this.error = '', this.showUsdcInfo = false, + this.backgroundColor = Colors.black, }); final TextEditingController inputController; @@ -33,6 +36,7 @@ class AmountWithEquivalent extends StatelessWidget { final Key? shakeKey; final String error; final bool showUsdcInfo; + final Color backgroundColor; @override Widget build(BuildContext context) => @@ -54,6 +58,7 @@ class AmountWithEquivalent extends StatelessWidget { child: _InputDisplay( input: value.text, fontSize: collapsed ? 57 : (context.isSmall ? 55 : 80), + token: token, ), ), if (!collapsed) @@ -77,7 +82,7 @@ class AmountWithEquivalent extends StatelessWidget { _ => _EquivalentDisplay( input: value.text, token: token, - backgroundColor: Colors.black, + backgroundColor: backgroundColor, ), }, ], @@ -117,11 +122,27 @@ class _EquivalentDisplay extends StatelessWidget { final String formattedAmount; if (shouldDisplay) { - formattedAmount = Amount.fromDecimal(value: value, currency: Currency.usd) - .let((it) => it as FiatAmount) - .let((it) => it.toTokenAmount(token)?.round(Currency.usd.decimals)) + formattedAmount = Amount.fromDecimal( + value: value, + currency: + token == Token.usdc ? Currency.usd : CryptoCurrency(token: token), + ) + .let( + (it) => switch (it) { + final FiatAmount fiat => + fiat.toTokenAmount(token)?.round(Currency.usd.decimals), + final CryptoAmount crypto => crypto.toFiatAmount( + defaultFiatCurrency, + ratesRepository: sl(), + ), + }, + ) .maybeFlatMap( - (it) => it.format(locale, roundInteger: true, skipSymbol: true), + (it) => it.format( + locale, + roundInteger: true, + skipSymbol: token == Token.usdc, + ), ) .ifNull(() => '0'); } else { @@ -139,14 +160,15 @@ class _EquivalentDisplay extends StatelessWidget { fontWeight: FontWeight.w700, ), ), - TextSpan( - text: ' ${token.symbol.toUpperCase()}', - style: const TextStyle( - color: CpColors.yellowColor, - fontSize: 15, - fontWeight: FontWeight.w700, + if (token == Token.usdc) + TextSpan( + text: ' ${Token.usdc.symbol.toUpperCase()}', + style: const TextStyle( + color: CpColors.yellowColor, + fontSize: 15, + fontWeight: FontWeight.w700, + ), ), - ), ], ), textAlign: TextAlign.center, @@ -218,16 +240,19 @@ class _InputDisplay extends StatelessWidget { const _InputDisplay({ required this.input, required this.fontSize, + required this.token, }); final String input; final double fontSize; + final Token token; @override Widget build(BuildContext context) { final sign = Currency.usd.sign; final amount = input.formatted(context); - final formatted = '$sign$amount'; + final formatted = + token == Token.usdc ? '$sign$amount' : '$amount ${token.symbol}'; return SizedBox( height: 94, diff --git a/packages/espressocash_app/lib/features/currency/models/amount.dart b/packages/espressocash_app/lib/features/currency/models/amount.dart index fb98660fbf..b1c37d535e 100644 --- a/packages/espressocash_app/lib/features/currency/models/amount.dart +++ b/packages/espressocash_app/lib/features/currency/models/amount.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:decimal/decimal.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:intl/intl.dart'; import '../../tokens/token.dart'; import 'currency.dart'; @@ -117,3 +120,43 @@ extension FiatAmountExt on FiatAmount { FiatAmount copyWithDecimal(Decimal decimal) => copyWith(value: currency.decimalToInt(decimal)); } + +extension FormattedAmount on Amount { + String formatRate(double rate, Locale locale) { + if (rate >= 1) { + return NumberFormat.currency( + locale: locale.toString(), + symbol: '', + decimalDigits: 2, + ).format(rate); + } + + String formattedRate = rate.toStringAsFixed(10); + int significantDigits = 0; + bool pastDecimalPoint = false; + bool trailingZero = true; + + for (int i = 0; i < formattedRate.length; i++) { + if (formattedRate[i] == '.') { + pastDecimalPoint = true; + } else if (pastDecimalPoint) { + if (formattedRate[i] != '0') { + trailingZero = false; + significantDigits++; + } else if (!trailingZero) { + significantDigits++; + } + if (significantDigits >= 2) { + formattedRate = formattedRate.substring(0, i + 1); + break; + } + } + } + + if (significantDigits < 2) { + formattedRate = rate.toStringAsFixed(2); + } + + return formattedRate; + } +} diff --git a/packages/espressocash_app/lib/features/outgoing_direct_payments/services/odp_service.dart b/packages/espressocash_app/lib/features/outgoing_direct_payments/services/odp_service.dart index 1ecf66b6bd..48df53cc86 100644 --- a/packages/espressocash_app/lib/features/outgoing_direct_payments/services/odp_service.dart +++ b/packages/espressocash_app/lib/features/outgoing_direct_payments/services/odp_service.dart @@ -168,7 +168,10 @@ class ODPService { ); if (newStatus is ODPStatusSuccess) { - _analyticsManager.directPaymentSent(amount: payment.amount.decimal); + _analyticsManager.directPaymentSent( + symbol: payment.amount.token.symbol, + amount: payment.amount.decimal, + ); } return newStatus == null ? payment : payment.copyWith(status: newStatus); diff --git a/packages/espressocash_app/lib/features/token_details/screens/token_details_screen.dart b/packages/espressocash_app/lib/features/token_details/screens/token_details_screen.dart new file mode 100644 index 0000000000..da8d469f97 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_details/screens/token_details_screen.dart @@ -0,0 +1,262 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../di.dart'; +import '../../../l10n/device_locale.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/theme.dart'; +import '../../../ui/value_stream_builder.dart'; +import '../../activities/services/tx_updater.dart'; +import '../../activities/widgets/recent_token_activity.dart'; +import '../../conversion_rates/data/repository.dart'; +import '../../conversion_rates/services/token_fiat_balance_service.dart'; +import '../../conversion_rates/widgets/extensions.dart'; +import '../../currency/models/amount.dart'; +import '../../currency/models/currency.dart'; +import '../../ramp/widgets/ramp_buttons.dart'; +import '../../token_send/screens/token_send_input_screen.dart'; +import '../../token_send/widgets/token_app_bar.dart'; +import '../../tokens/token.dart'; +import '../widgets/token_info.dart'; + +class TokenDetailsScreen extends StatelessWidget { + const TokenDetailsScreen({super.key, required this.token}); + + final Token token; + + static void push(BuildContext context, {required Token token}) => + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TokenDetailsScreen(token: token), + ), + ); + + @override + Widget build(BuildContext context) => Provider.value( + value: token, + child: CpTheme.dark( + child: Scaffold( + backgroundColor: CpColors.darkSandColor, + body: SafeArea( + bottom: false, + child: NestedScrollView( + headerSliverBuilder: (context, _) => + [TokenAppBar(token: token)], + body: _TokenDetailsBody(token), + ), + ), + ), + ), + ); +} + +class _TokenDetailsBody extends StatelessWidget { + const _TokenDetailsBody(this.token); + + final Token token; + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(31)), + child: LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints viewportConstraints, + ) => + RefreshIndicator( + onRefresh: () => sl().call(), + color: CpColors.primaryColor, + backgroundColor: Colors.white, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast, + parent: ClampingScrollPhysics(), + ), + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: viewportConstraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: 4), + const _TokenHeader(), + const SizedBox(height: 24), + if (token.isUsdcToken) + const _RampButtons() + else + _ActionButtons(token: token), + const SizedBox(height: 24), + Expanded( + child: DecoratedBox( + decoration: const BoxDecoration( + color: CpColors.deepGreyColor, + borderRadius: + BorderRadius.vertical(top: Radius.circular(31)), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 41), + child: Column( + children: [ + TokenInfo(tokenAddress: token.address), + RecentTokenActivityWidget( + tokenAddress: token.address, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); +} + +class _TokenHeader extends StatelessWidget { + const _TokenHeader(); + + @override + Widget build(BuildContext context) { + final token = context.watch(); + final rate = sl().readRate( + CryptoCurrency(token: token), + to: defaultFiatCurrency, + ) ?? + Decimal.zero; + + return ValueStreamBuilder( + create: () => ( + sl().readInvestmentBalance(token), + ( + Amount.zero(currency: Currency.usdc) as CryptoAmount, + Amount.zero(currency: Currency.usd) as FiatAmount + ) + ), + builder: (context, value) { + final crypto = value.$1; + final fiat = value.$2; + final fiatRate = + Amount.fromDecimal(value: rate, currency: Currency.usd); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + Text.rich( + TextSpan( + text: '${context.l10n.balance} ', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + ), + children: [ + TextSpan( + text: fiat?.format(context.locale), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + FittedBox( + child: Text( + crypto.format( + context.locale, + maxDecimals: 4, + ), + maxLines: 1, + style: const TextStyle( + fontSize: 59, + fontWeight: FontWeight.w700, + ), + ), + ), + Text.rich( + TextSpan( + text: '${context.l10n.price} ', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + ), + children: [ + TextSpan( + text: + '\$${fiatRate.formatRate(rate.toDouble(), context.locale)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class _RampButtons extends StatelessWidget { + const _RampButtons(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + AddCashButton(), + SizedBox(width: 16), + CashOutButton(), + ], + ), + ); +} + +class _ActionButtons extends StatelessWidget { + const _ActionButtons({required this.token}); + + final Token token; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + // ignore: avoid-single-child-column-or-row, won't be available in first release, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // TODO(dev): add swap button + // CpButton( + // text: 'Swap', + // minWidth: 106, + // size: CpButtonSize.big, + // onPressed: () {}, + // ), + // const SizedBox(width: 14), + CpButton( + text: context.l10n.send, + minWidth: 106, + size: CpButtonSize.big, + onPressed: () => TokenSendInputScreen.push( + context, + token: token, + ), + ), + ], + ), + ); +} diff --git a/packages/espressocash_app/lib/features/token_details/widgets/token_info.dart b/packages/espressocash_app/lib/features/token_details/widgets/token_info.dart new file mode 100644 index 0000000000..75eb6ba97d --- /dev/null +++ b/packages/espressocash_app/lib/features/token_details/widgets/token_info.dart @@ -0,0 +1,87 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../di.dart'; +import '../../../l10n/device_locale.dart'; +import '../../../l10n/l10n.dart'; +import '../../../utils/extensions.dart'; +import '../../conversion_rates/data/repository.dart'; +import '../../currency/models/amount.dart'; +import '../../currency/models/currency.dart'; +import '../../tokens/token.dart'; +import 'token_item.dart'; + +class TokenInfo extends StatelessWidget { + const TokenInfo({super.key, required this.tokenAddress}); + + final String tokenAddress; + + @override + Widget build(BuildContext context) { + final token = Provider.of(context); + + final rate = sl().readRate( + CryptoCurrency(token: token), + to: defaultFiatCurrency, + ) ?? + Decimal.zero; + + final fiatRate = Amount.fromDecimal(value: rate, currency: Currency.usd); + + return TokenItemContainer( + title: '${context.l10n.about} ${token.name}', + content: Column( + children: [ + _InfoItem( + label: token != Token.sol ? context.l10n.token : context.l10n.coin, + value: '${token.name} (${token.symbol})', + ), + _InfoItem( + label: context.l10n.price, + value: '\$${fiatRate.formatRate(rate.toDouble(), context.locale)}', + ), + if (token != Token.sol) + _InfoItem( + label: context.l10n.mintAddress, + value: tokenAddress.toShortAddress(), + ), + ], + ), + ); + } +} + +class _InfoItem extends StatelessWidget { + const _InfoItem({ + required this.label, + required this.value, + }); + final String label; + final String value; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Color(0xff999999), + ), + ), + ], + ), + ); +} diff --git a/packages/espressocash_app/lib/features/token_details/widgets/token_item.dart b/packages/espressocash_app/lib/features/token_details/widgets/token_item.dart new file mode 100644 index 0000000000..3c6e08fb04 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_details/widgets/token_item.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; + +import '../../../ui/colors.dart'; +import '../../../ui/theme.dart'; + +class TokenItemContainer extends StatelessWidget { + const TokenItemContainer({ + super.key, + required this.title, + required this.content, + }); + + final String title; + final Widget content; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: Text( + title, + style: dashboardSectionTitleTextStyle, + ), + ), + const SizedBox(height: 11), + Container( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 28), + decoration: const ShapeDecoration( + color: CpColors.blackGreyColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28)), + ), + ), + child: content, + ), + ], + ), + ); +} diff --git a/packages/espressocash_app/lib/features/token_send/screens/token_send_confirmation_screen.dart b/packages/espressocash_app/lib/features/token_send/screens/token_send_confirmation_screen.dart new file mode 100644 index 0000000000..52e684c369 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/screens/token_send_confirmation_screen.dart @@ -0,0 +1,262 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:solana/solana.dart'; + +import '../../../di.dart'; +import '../../../l10n/device_locale.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/dialogs.dart'; +import '../../../ui/number_formatter.dart'; +import '../../conversion_rates/widgets/amount_with_equivalent.dart'; +import '../../conversion_rates/widgets/extensions.dart'; +import '../../currency/models/amount.dart'; +import '../../fees/models/fee_type.dart'; +import '../../fees/services/fee_calculator.dart'; +import '../../tokens/token.dart'; +import '../widgets/token_app_bar.dart'; + +class TokenSendConfirmationScreen extends StatefulWidget { + const TokenSendConfirmationScreen({ + super.key, + required this.initialAmount, + required this.recipient, + required this.token, + }); + + static Future push( + BuildContext context, { + required String initialAmount, + required Ed25519HDPublicKey recipient, + required Token token, + }) => + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TokenSendConfirmationScreen( + initialAmount: initialAmount, + recipient: recipient, + token: token, + ), + ), + ); + + final String initialAmount; + final Ed25519HDPublicKey recipient; + final Token token; + + @override + State createState() => _ScreenState(); +} + +class _ScreenState extends State { + late final TextEditingController _amountController; + late Future _feeAmount; + + @override + void initState() { + super.initState(); + final feeType = FeeTypeDirect(widget.recipient); + + _amountController = TextEditingController(text: widget.initialAmount); + _feeAmount = sl().call(feeType); + } + + void _handleSubmitted() { + final locale = DeviceLocale.localeOf(context); + final amount = _amountController.text.toDecimalOrZero(locale); + if (amount == Decimal.zero) { + showWarningDialog( + context, + title: context.l10n.zeroAmountTitle, + message: context.l10n.zeroAmountMessage(context.l10n.operationSend), + ); + + return; + } + + Navigator.pop(context, amount); + } + + @override + void dispose() { + _amountController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: CpColors.deepGreyColor, + body: Stack( + children: [ + SafeArea( + minimum: const EdgeInsets.only(bottom: 40), + child: NestedScrollView( + headerSliverBuilder: (context, _) => [ + TokenAppBar( + token: widget.token, + color: CpColors.deepGreyColor, + displayText: false, + ), + ], + physics: const NeverScrollableScrollPhysics(), + body: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(31), + topRight: Radius.circular(31), + ), + child: LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints viewportConstraints, + ) => + DecoratedBox( + decoration: const BoxDecoration(), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: 36), + Expanded( + child: DecoratedBox( + decoration: const BoxDecoration( + color: CpColors.deepGreyColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(31), + topRight: Radius.circular(31), + ), + ), + child: Center( + child: Column( + children: [ + AmountWithEquivalent( + inputController: _amountController, + token: widget.token, + collapsed: false, + backgroundColor: CpColors.deepGreyColor, + ), + const SizedBox(height: 72), + FutureBuilder( + future: _feeAmount, + builder: (context, fee) => _InfoTable( + walletAddress: widget.recipient + .toString() + .shortened, + fees: context.feeStatus(fee), + ), + ), + ], + ), + ), + ), + ), + CpBottomButton( + text: context.l10n.send, + onPressed: _handleSubmitted, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); +} + +class _InfoTable extends StatelessWidget { + const _InfoTable({ + required this.walletAddress, + required this.fees, + }); + + final String walletAddress; + final String fees; + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.symmetric(horizontal: 22), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 28), + decoration: const ShapeDecoration( + color: CpColors.blackGreyColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28)), + ), + ), + child: Column( + children: [ + _InfoItem( + label: context.l10n.walletAddress, + value: walletAddress, + ), + const SizedBox(height: 6), + _InfoItem( + label: context.l10n.fees, + value: fees, + ), + ], + ), + ); +} + +class _InfoItem extends StatelessWidget { + const _InfoItem({ + required this.label, + required this.value, + }); + final String label; + final String value; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Color(0xff999999), + ), + ), + ], + ), + ); +} + +extension on String { + String get shortened { + if (length < 16) return this; + + return '${substring(0, 8)}...${substring(length - 8, length)}'; + } +} + +extension on BuildContext { + String feeStatus(AsyncSnapshot fee) { + if (fee.connectionState == ConnectionState.waiting) { + return 'Fetching fee...'; + } + + final data = fee.data; + if (!fee.hasData || data == null) { + return 'Unable to fetch fee'; + } + + final formattedFee = + data.format(DeviceLocale.localeOf(this), skipSymbol: true); + + return '\$$formattedFee'; + } +} diff --git a/packages/espressocash_app/lib/features/token_send/screens/token_send_input_screen.dart b/packages/espressocash_app/lib/features/token_send/screens/token_send_input_screen.dart new file mode 100644 index 0000000000..b347b5182e --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/screens/token_send_input_screen.dart @@ -0,0 +1,237 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:solana/solana.dart'; + +import '../../../di.dart'; +import '../../../gen/assets.gen.dart'; +import '../../../l10n/device_locale.dart'; +import '../../../l10n/l10n.dart'; +import '../../../ui/app_bar.dart'; +import '../../../ui/bottom_button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/icon_button.dart'; +import '../../../ui/number_formatter.dart'; +import '../../../ui/text_field.dart'; +import '../../../ui/value_stream_builder.dart'; +import '../../blockchain/models/blockchain.dart'; +import '../../conversion_rates/data/repository.dart'; +import '../../conversion_rates/services/token_fiat_balance_service.dart'; +import '../../currency/models/amount.dart'; +import '../../currency/models/currency.dart'; +import '../../outgoing_direct_payments/screens/odp_details_screen.dart'; +import '../../qr_scanner/widgets/build_context_ext.dart'; +import '../../tokens/token.dart'; +import '../widgets/extensions.dart'; +import '../widgets/token_input.dart'; +import 'token_send_confirmation_screen.dart'; + +class TokenSendInputScreen extends StatefulWidget { + const TokenSendInputScreen({super.key, required this.token}); + + static void push(BuildContext context, {required Token token}) => + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TokenSendInputScreen(token: token), + ), + ); + + final Token token; + + @override + State createState() => _TokenSendInputScreenState(); +} + +class _TokenSendInputScreenState extends State { + final TextEditingController _quantityController = TextEditingController(); + final TextEditingController _recipientController = TextEditingController(); + + late Decimal _rate; + + @override + void initState() { + super.initState(); + + _rate = sl().readRate( + CryptoCurrency(token: widget.token), + to: defaultFiatCurrency, + ) ?? + Decimal.zero; + } + + @override + void dispose() { + _quantityController.dispose(); + _recipientController.dispose(); + super.dispose(); + } + + Future _handleOnQrScan() async { + final code = await context.launchQrForAddress(); + + if (code == null) return; + if (!mounted) return; + + setState(() { + _recipientController.text = code; + }); + } + + Future _handlePressed() async { + final recipient = Ed25519HDPublicKey.fromBase58(_recipientController.text); + + final confirmedAmount = await TokenSendConfirmationScreen.push( + context, + token: widget.token, + initialAmount: _quantityController.text, + recipient: recipient, + ); + + if (confirmedAmount == null) return; + + final cryptoAmount = Amount.fromDecimal( + value: confirmedAmount, + currency: Currency.crypto(token: widget.token), + ) as CryptoAmount; + + if (!mounted) return; + final id = await context.createTokenSend( + amount: cryptoAmount, + receiver: recipient, + ); + + if (!mounted) return; + ODPDetailsScreen.open(context, id: id); + } + + bool _validateQuantity() { + if (_quantityController.text.isEmpty) return false; + + final amount = _quantityController.text + .toDecimalOrZero(DeviceLocale.localeOf(context)); + + return amount.toDouble() > 0; + } + + bool get _isValid => + Blockchain.solana.validateAddress(_recipientController.text) && + _validateQuantity(); + + @override + Widget build(BuildContext context) => ValueStreamBuilder( + create: () => ( + sl().readInvestmentBalance(widget.token), + ( + Amount.zero(currency: Currency.usdc) as CryptoAmount, + Amount.zero(currency: Currency.usd) as FiatAmount + ) + ), + builder: (context, value) { + final crypto = value.$1; + + return Scaffold( + appBar: CpAppBar( + title: Text( + context.l10n.send.toUpperCase(), + ), + ), + backgroundColor: CpColors.deepGreyColor, + body: SafeArea( + top: false, + minimum: const EdgeInsets.only(bottom: 40), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 23), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 30), + Padding( + padding: const EdgeInsets.only(left: 25.0), + child: Text( + context.l10n.quantity, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 8), + TokenQuantityInput( + quantityController: _quantityController, + crypto: crypto, + symbol: widget.token.symbol, + rate: _rate, + ), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.only(left: 25.0), + child: Text( + context.l10n.recipient, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 8), + _WalletTextField( + controller: _recipientController, + onQrScan: _handleOnQrScan, + ), + const Spacer(), + ListenableBuilder( + listenable: Listenable.merge( + [_quantityController, _recipientController], + ), + builder: (context, child) => CpBottomButton( + horizontalPadding: 16, + text: context.l10n.next, + onPressed: _isValid ? _handlePressed : null, + ), + ), + ], + ), + ), + ), + ); + }, + ); +} + +class _WalletTextField extends StatelessWidget { + const _WalletTextField({ + required this.controller, + required this.onQrScan, + }); + + final TextEditingController controller; + final VoidCallback onQrScan; + + @override + Widget build(BuildContext context) => CpTextField( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 24, + ), + height: 72, + controller: controller, + inputType: TextInputType.text, + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.none, + backgroundColor: CpColors.blackGreyColor, + placeholder: context.l10n.enterWalletAddress, + placeholderColor: CpColors.secondaryTextColor, + textColor: Colors.white, + fontSize: 16, + multiLine: true, + suffix: Padding( + padding: const EdgeInsets.only(right: 24), + child: CpIconButton( + onPressed: onQrScan, + variant: CpIconButtonVariant.inverted, + icon: Assets.icons.qrScanner.svg(color: Colors.white), + ), + ), + ); +} diff --git a/packages/espressocash_app/lib/features/token_send/services/token_send_service.dart b/packages/espressocash_app/lib/features/token_send/services/token_send_service.dart new file mode 100644 index 0000000000..2a112f9141 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/services/token_send_service.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:dfunc/dfunc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:solana/dto.dart' hide Instruction; +import 'package:solana/encoder.dart'; +import 'package:solana/solana.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../utils/transactions.dart'; +import '../../accounts/auth_scope.dart'; +import '../../accounts/models/ec_wallet.dart'; +import '../../analytics/analytics_manager.dart'; +import '../../balances/services/refresh_balance.dart'; +import '../../currency/models/amount.dart'; +import '../../outgoing_direct_payments/data/repository.dart'; +import '../../outgoing_direct_payments/models/outgoing_direct_payment.dart'; +import '../../tokens/token.dart'; +import '../../transactions/models/tx_results.dart'; +import '../../transactions/services/resign_tx.dart'; +import '../../transactions/services/tx_sender.dart'; + +@Singleton(scope: authScope) +class TokenSendService { + TokenSendService( + this._client, + this._repository, + this._txSender, + this._analyticsManager, + this._refreshBalance, + ); + + final SolanaClient _client; + final ODPRepository _repository; + final TxSender _txSender; + final AnalyticsManager _analyticsManager; + final RefreshBalance _refreshBalance; + + final Map> _subscriptions = {}; + + Future create({ + required ECWallet account, + required CryptoAmount amount, + required Ed25519HDPublicKey receiver, + }) async { + final id = const Uuid().v4(); + + final status = amount.token == Token.sol + ? await _createSolTransferTx( + account: account, + receiver: receiver, + amount: amount, + ) + : await _createTokenTransferTx( + account: account, + receiver: receiver, + amount: amount, + ); + + final payment = OutgoingDirectPayment( + id: id, + receiver: receiver, + amount: amount, + created: DateTime.now(), + status: status, + ); + + await _repository.save(payment); + _subscribe(id); + + return payment; + } + + Future cancel(String paymentId) async { + final payment = await _repository.load(paymentId); + if (payment == null || !payment.isRetriable) return; + + await _repository.delete(paymentId); + } + + Future _createSolTransferTx({ + required CryptoAmount amount, + required ECWallet account, + required Ed25519HDPublicKey receiver, + }) async { + try { + final lamports = amount.value; + + final Message message = Message( + instructions: [ + SystemInstruction.transfer( + fundingAccount: account.publicKey, + lamports: lamports, + recipientAccount: receiver, + ), + ], + ); + + final blockhash = await _client.rpcClient.getLatestBlockhash(); + final compiled = message.compile( + recentBlockhash: blockhash.value.blockhash, + feePayer: account.publicKey, + ); + + final tx = await SignedTx( + compiledMessage: compiled, + signatures: [account.publicKey.emptySignature()], + ).let((it) => it.resign(account)); + + return ODPStatus.txCreated(tx, slot: blockhash.context.slot); + } on Exception { + return const ODPStatus.txFailure( + reason: TxFailureReason.creatingFailure, + ); + } + } + + Future _createTokenTransferTx({ + required CryptoAmount amount, + required ECWallet account, + required Ed25519HDPublicKey receiver, + }) async { + try { + final mint = amount.token.publicKey; + final sender = account.publicKey; + const commitment = Commitment.confirmed; + + final shouldCreateAta = !await _client.hasAssociatedTokenAccount( + owner: receiver, + mint: mint, + commitment: commitment, + ); + + final token = await _client.rpcClient + .getAccountInfo( + mint.toBase58(), + commitment: commitment, + encoding: Encoding.base64, + ) + .then((it) => it.value); + + final tokenProgram = token?.owner.toType; + + if (tokenProgram == null) { + return const ODPStatus.txFailure( + reason: TxFailureReason.creatingFailure, + ); + } + + final instructions = []; + + final ataSender = await findAssociatedTokenAddress( + owner: sender, + mint: mint, + tokenProgramType: tokenProgram, + ); + + final ataReceiver = await findAssociatedTokenAddress( + owner: receiver, + mint: mint, + tokenProgramType: tokenProgram, + ); + + if (shouldCreateAta) { + final iCreateATA = AssociatedTokenAccountInstruction.createAccount( + funder: sender, + address: ataReceiver, + owner: receiver, + mint: mint, + tokenProgramId: tokenProgram.id, + ); + instructions.add(iCreateATA); + } + + final iTransfer = TokenInstruction.transferChecked( + amount: amount.value, + decimals: amount.token.decimals, + mint: mint, + source: ataSender, + destination: ataReceiver, + owner: sender, + tokenProgram: tokenProgram, + ); + + instructions.add(iTransfer); + + final message = Message(instructions: instructions); + + final blockhash = await _client.rpcClient.getLatestBlockhash( + commitment: commitment, + ); + final compiled = message.compile( + recentBlockhash: blockhash.value.blockhash, + feePayer: account.publicKey, + ); + + final tx = await SignedTx( + compiledMessage: compiled, + signatures: [account.publicKey.emptySignature()], + ).let((it) => it.resign(account)); + + return ODPStatus.txCreated(tx, slot: blockhash.context.slot); + } on Exception { + return const ODPStatus.txFailure( + reason: TxFailureReason.creatingFailure, + ); + } + } + + void _subscribe(String paymentId) { + _subscriptions[paymentId] = _repository + .watch(paymentId) + .asyncExpand((payment) { + switch (payment.status) { + case ODPStatusTxCreated(): + return _send(payment).asStream(); + case ODPStatusTxSent(): + return _wait(payment).asStream(); + case ODPStatusSuccess(): + case ODPStatusTxFailure(): + _subscriptions.remove(paymentId)?.cancel(); + + return null; + } + }).listen((payment) => payment?.let(_repository.save)); + } + + Future _send(OutgoingDirectPayment payment) async { + final status = payment.status; + if (status is! ODPStatusTxCreated) { + return payment; + } + + final tx = await _txSender.send(status.tx, minContextSlot: status.slot); + + final ODPStatus? newStatus = tx.map( + sent: (_) => ODPStatus.txSent( + status.tx, + slot: status.slot, + ), + invalidBlockhash: (_) => const ODPStatus.txFailure( + reason: TxFailureReason.invalidBlockhashSending, + ), + failure: (it) => ODPStatus.txFailure(reason: it.reason), + networkError: (_) => null, + ); + + return newStatus == null ? payment : payment.copyWith(status: newStatus); + } + + Future _wait(OutgoingDirectPayment payment) async { + final status = payment.status; + if (status is! ODPStatusTxSent) { + return payment; + } + + final tx = await _txSender.wait( + status.tx, + minContextSlot: status.slot, + txType: 'OutgoingTokenPayment', + ); + + final ODPStatus? newStatus = tx.map( + success: (_) => ODPStatus.success(txId: status.tx.id), + failure: (tx) => ODPStatus.txFailure(reason: tx.reason), + networkError: (_) => null, + ); + + if (newStatus is ODPStatusSuccess) { + _refreshBalance(); + + _analyticsManager.directPaymentSent( + symbol: payment.amount.token.symbol, + amount: payment.amount.decimal, + ); + } + + return newStatus == null ? payment : payment.copyWith(status: newStatus); + } +} + +extension on String? { + TokenProgramType? get toType => switch (this) { + Token2022Program.programId => TokenProgramType.token2022Program, + TokenProgram.programId => TokenProgramType.tokenProgram, + _ => null, + }; +} diff --git a/packages/espressocash_app/lib/features/token_send/widgets/extensions.dart b/packages/espressocash_app/lib/features/token_send/widgets/extensions.dart new file mode 100644 index 0000000000..92ccd2cc49 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/widgets/extensions.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:solana/solana.dart'; + +import '../../../di.dart'; +import '../../../ui/loader.dart'; +import '../../accounts/models/account.dart'; +import '../../currency/models/amount.dart'; +import '../services/token_send_service.dart'; + +extension BuildContextExt on BuildContext { + Future createTokenSend({ + required CryptoAmount amount, + required Ed25519HDPublicKey receiver, + }) => + runWithLoader(this, () async { + final payment = await sl().create( + account: sl().wallet, + amount: amount, + receiver: receiver, + ); + + return payment.id; + }); +} diff --git a/packages/espressocash_app/lib/features/token_send/widgets/token_app_bar.dart b/packages/espressocash_app/lib/features/token_send/widgets/token_app_bar.dart new file mode 100644 index 0000000000..777581ab60 --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/widgets/token_app_bar.dart @@ -0,0 +1,131 @@ +import 'dart:math'; + +import 'package:dfunc/dfunc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../../ui/back_button.dart'; +import '../../../ui/colors.dart'; +import '../../tokens/token.dart'; +import '../../tokens/widgets/token_icon.dart'; + +class TokenAppBar extends StatelessWidget { + const TokenAppBar({ + super.key, + required this.token, + this.color = CpColors.darkSandColor, + this.displayText = true, + }); + + final Token token; + final Color color; + final bool displayText; + + @override + Widget build(BuildContext context) => SliverPersistentHeader( + pinned: true, + delegate: _TokenAppBarDelegate(token, color, displayText: displayText), + ); +} + +class _TokenAppBarDelegate extends SliverPersistentHeaderDelegate { + const _TokenAppBarDelegate( + this.token, + this.color, { + required this.displayText, + }); + + final Token token; + final Color color; + final bool displayText; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + /// Scroll ratio, should vary between [0,1] from expanded to collapsed. + final ratio = (shrinkOffset / maxExtent) + .let(Curves.ease.transform) + .let((it) => 1 - it); + final iconSize = max(_tokenSize * ratio, 24.0); + + return Material( + color: color, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + children: [ + _buildIcon(ratio, iconSize), + if (displayText) _buildText(ratio, iconSize), + const _BackButton(), + ], + ), + ), + ); + } + + Widget _buildIcon(double ratio, double iconSize) => Positioned( + top: (iconSize * ratio) - iconSize + 8, + left: 0, + right: 0, + child: Opacity( + opacity: ratio, + child: TokenIcon(token: token, size: iconSize), + ), + ); + + Widget _buildText(double ratio, double iconSize) => Positioned.fill( + top: iconSize * (ratio * 1.15) + 4, + left: _buttonSize, + right: _buttonSize, + child: Center( + child: FittedBox( + child: Text( + '${token.name} (${token.symbol})', + maxLines: 1, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 17, + ), + ), + ), + ), + ); + + @override + double get maxExtent => _tokenSize + (_minExtent - 20); + + @override + double get minExtent => _minExtent; + + @override + bool shouldRebuild(covariant _TokenAppBarDelegate oldDelegate) => + oldDelegate.token != token; + + @override + FloatingHeaderSnapConfiguration get snapConfiguration => + FloatingHeaderSnapConfiguration( + curve: Curves.easeIn, + duration: const Duration(milliseconds: 200), + ); +} + +class _BackButton extends StatelessWidget { + const _BackButton(); + + @override + Widget build(BuildContext context) => const Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: _minExtent, + child: CpBackButton(), + ), + ); +} + +const double _tokenSize = 68; +const double _buttonSize = 48; +const double _minExtent = kToolbarHeight; diff --git a/packages/espressocash_app/lib/features/token_send/widgets/token_input.dart b/packages/espressocash_app/lib/features/token_send/widgets/token_input.dart new file mode 100644 index 0000000000..b305b5b2ab --- /dev/null +++ b/packages/espressocash_app/lib/features/token_send/widgets/token_input.dart @@ -0,0 +1,116 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; + +import '../../../l10n/device_locale.dart'; +import '../../../ui/button.dart'; +import '../../../ui/colors.dart'; +import '../../../ui/number_formatter.dart'; +import '../../../ui/text_field.dart'; +import '../../conversion_rates/widgets/extensions.dart'; +import '../../currency/models/amount.dart'; + +class TokenQuantityInput extends StatefulWidget { + const TokenQuantityInput({ + super.key, + required TextEditingController quantityController, + required this.crypto, + required this.rate, + required this.symbol, + }) : _quantityController = quantityController; + + final TextEditingController _quantityController; + final CryptoAmount crypto; + final String symbol; + final Decimal rate; + + @override + State createState() => _TokenQuantityInputState(); +} + +class _TokenQuantityInputState extends State { + bool _showUsdcEquivalent = false; + double _textHeight = 1.2; + + @override + void initState() { + super.initState(); + widget._quantityController.addListener(_quantityListener); + } + + void _quantityListener() { + final isValueValid = widget._quantityController.text.isNotEmpty; + + setState(() { + if (isValueValid) { + _textHeight = 0.9; + _showUsdcEquivalent = true; + } else { + _textHeight = 1.2; + _showUsdcEquivalent = false; + } + }); + } + + @override + Widget build(BuildContext context) => Stack( + children: [ + CpTextField( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 24, + ), + height: 72, + controller: widget._quantityController, + inputType: const TextInputType.numberWithOptions(decimal: true), + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.none, + backgroundColor: CpColors.blackGreyColor, + placeholder: '0 ${widget.symbol}', + placeholderColor: Colors.white, + textColor: Colors.white, + fontSize: 34, + fontWeight: FontWeight.w700, + maxLength: 29, + textHeight: _textHeight, + suffix: Padding( + padding: const EdgeInsets.only(right: 14), + child: CpButton( + onPressed: _isMaxAmountZero ? _callback : null, + text: _buttonText, + minWidth: 54, + size: CpButtonSize.small, + variant: CpButtonVariant.inverted, + ), + ), + ), + Visibility( + visible: _showUsdcEquivalent, + child: Positioned( + left: 28, + bottom: 4, + child: Text( + _usdcAmount, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ), + ], + ); + + Decimal get _parsedAmount => widget._quantityController.text + .toDecimalOrZero(DeviceLocale.localeOf(context)); + + bool get _isMax => _parsedAmount == widget.crypto.decimal; + + bool get _isMaxAmountZero => widget.crypto.decimal > Decimal.zero; + + String get _usdcAmount => + r'≈ $' + (_parsedAmount * widget.rate).toStringAsFixed(2); + + String get _buttonText => _isMax ? 'Clear' : 'Max'; + + VoidCallback get _callback => _isMax + ? widget._quantityController.clear + : () => widget._quantityController.text = widget.crypto + .format(DeviceLocale.localeOf(context), skipSymbol: true); +} diff --git a/packages/espressocash_app/lib/features/tokens/token.dart b/packages/espressocash_app/lib/features/tokens/token.dart index 269e2d6cd9..17ef6ea066 100644 --- a/packages/espressocash_app/lib/features/tokens/token.dart +++ b/packages/espressocash_app/lib/features/tokens/token.dart @@ -150,3 +150,7 @@ class _UsdcDevToken extends SplToken { isStablecoin: true, ); } + +extension TokenUtils on Token { + bool get isUsdcToken => address == Token.usdc.address; +} diff --git a/packages/espressocash_app/lib/l10n/intl_en.arb b/packages/espressocash_app/lib/l10n/intl_en.arb index 51458fedcd..4af098b8c1 100644 --- a/packages/espressocash_app/lib/l10n/intl_en.arb +++ b/packages/espressocash_app/lib/l10n/intl_en.arb @@ -82,6 +82,8 @@ "@faq": {}, "fee": "Fee", "@fee": {}, + "fees": "Fees", + "@fees": {}, "feesCalculating": "calculating...", "@feesCalculating": {}, "feesFailed": "failed to load", @@ -1205,5 +1207,29 @@ "pendingKycDialogMessage": "Your identity verification is currently under review and will be completed shortly. You can check the Activity page for real-time updates on your verification status.", "@pendingKycDialogMessage": {}, "continueVerification": "Continue Verification", - "@continueVerification": {} -} + "@continueVerification": {}, + "about": "About", + "@about": {}, + "coin": "Coin", + "@coin": {}, + "mintAddress": "Mint Address", + "@mintAddress": {}, + "token" : "Token", + "@token": {}, + "price": "Price", + "@price": {}, + "today": "Today", + "@today": {}, + "yesterday": "Yesterday", + "@yesterday": {}, + "balance": "Balance", + "@balance": {}, + "noRecentActivity": "No recent activity", + "@noRecentActivity": {}, + "quantity": "Quantity", + "@quantity": {}, + "recipient": "Recipient", + "@recipient": {}, + "enterWalletAddress": "Enter a wallet address", + "@enterWalletAddress": {} +} \ No newline at end of file diff --git a/packages/espressocash_app/lib/ui/icon_button.dart b/packages/espressocash_app/lib/ui/icon_button.dart index d1a2efec54..d879d3f7a9 100644 --- a/packages/espressocash_app/lib/ui/icon_button.dart +++ b/packages/espressocash_app/lib/ui/icon_button.dart @@ -9,6 +9,7 @@ enum CpIconButtonVariant { light, black, transparent, + inverted, } enum CpIconButtonSize { @@ -39,6 +40,8 @@ class CpIconButton extends StatelessWidget { return CpColors.yellowColor; case CpIconButtonVariant.grey: return CpColors.greyIconBackgroundColor; + case CpIconButtonVariant.inverted: + return CpColors.deepGreyColor; case CpIconButtonVariant.light: return Colors.white; case CpIconButtonVariant.black: diff --git a/packages/espressocash_app/lib/ui/text_field.dart b/packages/espressocash_app/lib/ui/text_field.dart index 3a6c992dab..8b2eaebaf3 100644 --- a/packages/espressocash_app/lib/ui/text_field.dart +++ b/packages/espressocash_app/lib/ui/text_field.dart @@ -30,6 +30,9 @@ class CpTextField extends StatelessWidget { this.multiLine = false, this.textCapitalization = TextCapitalization.none, this.autocorrect = true, + this.height, + this.textHeight = 1.2, + this.maxLength, }); final TextEditingController? controller; @@ -41,6 +44,9 @@ class CpTextField extends StatelessWidget { final bool readOnly; final double fontSize; final FontWeight fontWeight; + final double? height; + final double textHeight; + final int? maxLength; final bool disabled; final TextInputType? inputType; final List? inputFormatters; @@ -60,6 +66,7 @@ class CpTextField extends StatelessWidget { final multiLine = this.multiLine ?? false; return Container( + height: height, margin: margin, decoration: border == CpTextFieldBorder.stadium ? ShapeDecoration( @@ -76,6 +83,7 @@ class CpTextField extends StatelessWidget { decoration: const BoxDecoration(), suffix: suffix, padding: padding, + maxLength: maxLength, readOnly: readOnly, textAlignVertical: TextAlignVertical.center, controller: controller, @@ -85,7 +93,7 @@ class CpTextField extends StatelessWidget { fontWeight: fontWeight, fontSize: fontSize, color: textColor, - height: 1.2, + height: textHeight, ), textAlign: textAlign, placeholder: placeholder, diff --git a/packages/espressocash_app/lib/utils/chunks.dart b/packages/espressocash_app/lib/utils/chunks.dart new file mode 100644 index 0000000000..f6eed24d22 --- /dev/null +++ b/packages/espressocash_app/lib/utils/chunks.dart @@ -0,0 +1,17 @@ +extension IterableX on Iterable { + Iterable> chunks(int size) sync* { + if (isEmpty) return; + + List chunk = []; + for (final element in this) { + chunk.add(element); + if (chunk.length == size) { + yield chunk; + chunk = []; + } + } + if (chunk.isNotEmpty) { + yield chunk; + } + } +} diff --git a/packages/espressocash_app/moor_schemas/moor_schema_v61.json b/packages/espressocash_app/moor_schemas/moor_schema_v61.json new file mode 100644 index 0000000000..d57240c672 --- /dev/null +++ b/packages/espressocash_app/moor_schemas/moor_schema_v61.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.1.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"outgoing_transfer_rows","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":1,"references":[],"type":"table","data":{"name":"payment_request_rows","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"dynamic_link","getter_name":"dynamicLink","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"short_link","getter_name":"shortLink","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(PaymentRequestStateDto.values)","dart_type_name":"PaymentRequestStateDto"}},{"name":"transaction_id","getter_name":"transactionId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"resolved_at","getter_name":"resolvedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"recipient","getter_name":"recipient","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"amount","getter_name":"amount","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"splt_token","getter_name":"spltToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"reference","getter_name":"reference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"message","getter_name":"message","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"memo","getter_name":"memo","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"o_d_p_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"receiver","getter_name":"receiver","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"reference","getter_name":"reference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(ODPStatusDto.values)","dart_type_name":"ODPStatusDto"}},{"name":"tx","getter_name":"tx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_id","getter_name":"txId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_failure_reason","getter_name":"txFailureReason","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TxFailureReason.values)","dart_type_name":"TxFailureReason"}},{"name":"slot","getter_name":"slot","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"transaction_rows","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"encoded_tx","getter_name":"encodedTx","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TxCommonStatus.values)","dart_type_name":"TxCommonStatus"}},{"name":"amount","getter_name":"amount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":4,"references":[],"type":"table","data":{"name":"o_l_p_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(OLPStatusDto.values)","dart_type_name":"OLPStatusDto"}},{"name":"tx","getter_name":"tx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_id","getter_name":"txId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"withdraw_tx_id","getter_name":"withdrawTxId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"link","getter_name":"link","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_failure_reason","getter_name":"txFailureReason","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TxFailureReason.values)","dart_type_name":"TxFailureReason"}},{"name":"cancel_tx","getter_name":"cancelTx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"cancel_tx_id","getter_name":"cancelTxId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"generated_links_at","getter_name":"generatedLinksAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"resolved_at","getter_name":"resolvedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"slot","getter_name":"slot","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":5,"references":[],"type":"table","data":{"name":"i_l_p_rows","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx","getter_name":"tx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_id","getter_name":"txId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_failure_reason","getter_name":"txFailureReason","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TxFailureReason.values)","dart_type_name":"TxFailureReason"}},{"name":"slot","getter_name":"slot","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(ILPStatusDto.values)","dart_type_name":"ILPStatusDto"}},{"name":"fee_amount","getter_name":"feeAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"receive_amount","getter_name":"receiveAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":6,"references":[],"type":"table","data":{"name":"on_ramp_order_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_completed","getter_name":"isCompleted","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_completed\" IN (0, 1))","default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"human_status","getter_name":"humanStatus","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"machine_status","getter_name":"machineStatus","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"partner_order_id","getter_name":"partnerOrderId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"receive_amount","getter_name":"receiveAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_hash","getter_name":"txHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"partner","getter_name":"partner","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('kado')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(RampPartner.values)","dart_type_name":"RampPartner"}},{"name":"status","getter_name":"status","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(OnRampOrderStatus.values)","dart_type_name":"OnRampOrderStatus"}},{"name":"bank_name","getter_name":"bankName","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bank_account","getter_name":"bankAccount","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bank_transfer_expiry","getter_name":"bankTransferExpiry","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bank_transfer_amount","getter_name":"bankTransferAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"fiat_symbol","getter_name":"fiatSymbol","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"auth_token","getter_name":"authToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"more_info_url","getter_name":"moreInfoUrl","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"stellar_tx_hash","getter_name":"stellarTxHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"fee_amount","getter_name":"feeAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"reference_number","getter_name":"referenceNumber","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bridge_amount","getter_name":"bridgeAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":7,"references":[],"type":"table","data":{"name":"off_ramp_order_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(OffRampOrderStatus.values)","dart_type_name":"OffRampOrderStatus"}},{"name":"human_status","getter_name":"humanStatus","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"machine_status","getter_name":"machineStatus","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"partner_order_id","getter_name":"partnerOrderId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"transaction","getter_name":"transaction","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deposit_address","getter_name":"depositAddress","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"slot","getter_name":"slot","moor_type":"bigInt","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"resolved_at","getter_name":"resolvedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"receive_amount","getter_name":"receiveAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"fiat_symbol","getter_name":"fiatSymbol","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"partner","getter_name":"partner","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('kado')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(RampPartner.values)","dart_type_name":"RampPartner"}},{"name":"fee_amount","getter_name":"feeAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"fee_token","getter_name":"feeToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"auth_token","getter_name":"authToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"withdraw_anchor_account","getter_name":"withdrawAnchorAccount","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"withdraw_memo","getter_name":"withdrawMemo","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"withdraw_url","getter_name":"withdrawUrl","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"more_info_url","getter_name":"moreInfoUrl","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"solana_bridge_tx","getter_name":"solanaBridgeTx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"stellar_tx_hash","getter_name":"stellarTxHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bridge_amount","getter_name":"bridgeAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"reference_number","getter_name":"referenceNumber","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"refund_amount","getter_name":"refundAmount","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"priority_fee","getter_name":"priorityFee","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"gas_fee","getter_name":"gasFee","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":8,"references":[],"type":"table","data":{"name":"outgoing_dln_payment_rows","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx","getter_name":"tx","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_id","getter_name":"txId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"tx_failure_reason","getter_name":"txFailureReason","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TxFailureReason.values)","dart_type_name":"TxFailureReason"}},{"name":"slot","getter_name":"slot","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"receiver_blockchain","getter_name":"receiverBlockchain","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlockchainDto.values)","dart_type_name":"BlockchainDto"}},{"name":"receiver_address","getter_name":"receiverAddress","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(ODLNPaymentStatusDto.values)","dart_type_name":"ODLNPaymentStatusDto"}},{"name":"order_id","getter_name":"orderId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":9,"references":[],"type":"table","data":{"name":"transaction_request_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created","getter_name":"created","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"label","getter_name":"label","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"transaction","getter_name":"transaction","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"slot","getter_name":"slot","moor_type":"bigInt","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"status","getter_name":"status","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(TRStatusDto.values)","dart_type_name":"TRStatusDto"}}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}},{"id":10,"references":[],"type":"table","data":{"name":"token_balance_rows","was_declared_in_moor":false,"columns":[{"name":"amount","getter_name":"amount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["token"]}},{"id":11,"references":[],"type":"table","data":{"name":"conversion_rates_rows","was_declared_in_moor":false,"columns":[{"name":"token","getter_name":"token","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"fiat_currency","getter_name":"fiatCurrency","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rate","getter_name":"rate","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["token","fiat_currency"]}},{"id":12,"references":[],"type":"table","data":{"name":"token_rows","was_declared_in_moor":false,"columns":[{"name":"chain_id","getter_name":"chainId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"address","getter_name":"address","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"symbol","getter_name":"symbol","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"decimals","getter_name":"decimals","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"logo_u_r_i","getter_name":"logoURI","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_stablecoin","getter_name":"isStablecoin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_stablecoin\" IN (0, 1))","default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["chain_id","address"]}}]} \ No newline at end of file diff --git a/packages/espressocash_app/test/stub_analytics_manager.dart b/packages/espressocash_app/test/stub_analytics_manager.dart index ae2e609588..c976601ed8 100644 --- a/packages/espressocash_app/test/stub_analytics_manager.dart +++ b/packages/espressocash_app/test/stub_analytics_manager.dart @@ -6,7 +6,7 @@ class StubAnalyticsManager implements AnalyticsManager { const StubAnalyticsManager(); @override - void directPaymentSent({required Decimal amount}) {} + void directPaymentSent({required String symbol, required Decimal amount}) {} @override void setProfileCountryCode(String countryCode) {}