diff --git a/mobile-app/analysis_options.yaml b/mobile-app/analysis_options.yaml index 1c434ce4..8f9a4af9 100644 --- a/mobile-app/analysis_options.yaml +++ b/mobile-app/analysis_options.yaml @@ -21,6 +21,7 @@ analyzer: - "**/*.g.dart" - "**/*.freezed.dart" - "lib/generated/**" + - "build/**" errors: avoid_print: ignore # print is the most reliable way to debug the app missing_required_param: error diff --git a/mobile-app/lib/models/pagination_state.dart b/mobile-app/lib/models/pagination_state.dart index e0f685f1..7bfd1b8c 100644 --- a/mobile-app/lib/models/pagination_state.dart +++ b/mobile-app/lib/models/pagination_state.dart @@ -8,9 +8,12 @@ class PaginationState { final int otherOffset; final bool hasMore; final bool isFetching; + final bool isLoading; final Object? error; final StackTrace? stackTrace; + bool get hasLoadedChainData => otherTransfers.isNotEmpty || scheduledReversibleTransfers.isNotEmpty; + PaginationState({ required this.otherTransfers, required this.scheduledReversibleTransfers, @@ -18,13 +21,25 @@ class PaginationState { this.otherOffset = 0, required this.hasMore, required this.isFetching, + required this.isLoading, this.error, this.stackTrace, }); - factory PaginationState.initial() => - PaginationState(otherTransfers: [], scheduledReversibleTransfers: [], hasMore: true, isFetching: false); + factory PaginationState.initial() => PaginationState( + otherTransfers: [], + scheduledReversibleTransfers: [], + hasMore: true, + isFetching: false, + isLoading: true, + ); + /// Returns a copy with the given fields replaced. + /// + /// For [error] and [stackTrace]: omitted arguments keep the current values. + /// Pass [error] and/or [stackTrace] to set them. Pass [clearError] true to + /// set both to null; [clearError] takes precedence over [error] and + /// [stackTrace] when all are provided. PaginationState copyWith({ List? otherTransfers, List? scheduledReversibleTransfers, @@ -32,8 +47,10 @@ class PaginationState { int? otherOffset, bool? hasMore, bool? isFetching, + bool? isLoading, Object? error, StackTrace? stackTrace, + bool clearError = false, }) { return PaginationState( otherTransfers: otherTransfers ?? this.otherTransfers, @@ -42,8 +59,9 @@ class PaginationState { otherOffset: otherOffset ?? this.otherOffset, hasMore: hasMore ?? this.hasMore, isFetching: isFetching ?? this.isFetching, - error: error ?? this.error, - stackTrace: stackTrace ?? this.stackTrace, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + stackTrace: clearError ? null : (stackTrace ?? this.stackTrace), ); } } diff --git a/mobile-app/lib/providers/active_account_transactions_provider.dart b/mobile-app/lib/providers/active_account_transactions_provider.dart index 0cbbc8f5..4824a1ba 100644 --- a/mobile-app/lib/providers/active_account_transactions_provider.dart +++ b/mobile-app/lib/providers/active_account_transactions_provider.dart @@ -2,10 +2,41 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/models/combined_transactions_list.dart'; import 'package:resonance_network_wallet/models/filtered_transactions_params.dart'; +import 'package:resonance_network_wallet/models/pagination_state.dart'; import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/controllers/unified_pagination_controller.dart'; import 'package:resonance_network_wallet/providers/filtered_all_transactions_provider.dart'; +FilteredTransactionsParams? activeAccountFilteredParams(DisplayAccount? activeAccount, TransactionFilter filter) { + if (activeAccount == null) return null; + return FilteredTransactionsParams( + accountIds: AccountIdListCache.get([activeAccount.account.accountId]), + filter: filter, + ); +} + +UnifiedPaginationController? readActiveAccountPaginationNotifier(WidgetRef ref, TransactionFilter filter) { + final params = activeAccountFilteredParams(ref.read(activeAccountProvider).value, filter); + if (params == null) return null; + return ref.read(filteredPaginationControllerProviderFamily(params).notifier); +} + +/// Pagination state for the active account and [TransactionFilter]. +final activeAccountPaginationProvider = Provider.family((ref, filter) { + final activeAccountValue = ref.watch(activeAccountProvider); + + return activeAccountValue.when( + data: (activeAccount) { + final params = activeAccountFilteredParams(activeAccount, filter); + if (params == null) return null; + return ref.watch(filteredPaginationControllerProviderFamily(params)); + }, + loading: () => null, + error: (_, _) => null, + ); +}); + /// Provides a filtered list of transactions for the currently active account. /// /// Parameterised by [TransactionFilter] so callers can independently watch @@ -21,10 +52,7 @@ final activeAccountTransactionsProvider = Provider.family const AsyncValue.loading(), diff --git a/mobile-app/lib/providers/all_transactions_provider.dart b/mobile-app/lib/providers/all_transactions_provider.dart index 91fa35e9..30d1a2bb 100644 --- a/mobile-app/lib/providers/all_transactions_provider.dart +++ b/mobile-app/lib/providers/all_transactions_provider.dart @@ -5,6 +5,7 @@ import 'package:resonance_network_wallet/models/pagination_state.dart'; import 'package:resonance_network_wallet/providers/controllers/unified_pagination_controller.dart'; import 'package:resonance_network_wallet/providers/pending_cancellations_provider.dart'; import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; final paginationControllerProvider = StateNotifierProvider( (ref) => UnifiedPaginationController(ref), @@ -16,10 +17,14 @@ final allTransactionsProvider = Provider>(( final pending = ref.watch(pendingTransactionsProvider); final pagination = ref.watch(paginationControllerProvider); - if (pagination.error != null) { + if (pagination.error != null && !pagination.hasLoadedChainData) { + quantusDebugPrint('AllTransactionsProvider: Error: ${pagination.error}'); return AsyncValue.error(pagination.error!, pagination.stackTrace!); } - if (pagination.isFetching && pagination.otherTransfers.isEmpty) { + if (pagination.error != null) { + quantusDebugPrint('AllTransactionsProvider: Load-more error: ${pagination.error}'); + } + if (pagination.isLoading && !pagination.hasLoadedChainData) { return const AsyncValue.loading(); } diff --git a/mobile-app/lib/providers/controllers/unified_pagination_controller.dart b/mobile-app/lib/providers/controllers/unified_pagination_controller.dart index 48c6e19d..fcd50df4 100644 --- a/mobile-app/lib/providers/controllers/unified_pagination_controller.dart +++ b/mobile-app/lib/providers/controllers/unified_pagination_controller.dart @@ -6,6 +6,7 @@ import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/providers/connectivity_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; /// Unified pagination controller that handles both all-accounts and /// filtered-accounts scenarios @@ -42,13 +43,13 @@ class UnifiedPaginationController extends StateNotifier { try { ids = await _getAccountIdsAsync(); } catch (e, st) { - print('Initialization failed: $e\n$st'); - state = state.copyWith(error: e, stackTrace: st); + quantusDebugPrint('Initialization failed: $e\n$st'); + state = state.copyWith(error: e, stackTrace: st, isLoading: false); return; } if (ids.isEmpty) { - state = state.copyWith(hasMore: false, isFetching: false); + state = state.copyWith(hasMore: false, isFetching: false, isLoading: false); return; } @@ -77,7 +78,7 @@ class UnifiedPaginationController extends StateNotifier { Future _fetchPage(List targetAccountIds) async { try { - state = state.copyWith(isFetching: true); + state = state.copyWith(isFetching: true, isLoading: true, clearError: true); final newTransactions = await ref .read(chainHistoryServiceProvider) .fetchAllTransactionTypes( @@ -98,17 +99,17 @@ class UnifiedPaginationController extends StateNotifier { scheduledOffset: newTransactions.nextScheduledOffset, hasMore: newTransactions.hasMore, isFetching: false, - error: null, - stackTrace: null, + isLoading: false, + clearError: true, ); } catch (e, st) { - print('Fetch page failed: $e\n$st'); - state = state.copyWith(error: e, stackTrace: st, isFetching: false); + quantusDebugPrint('Fetch page failed: $e\n$st'); + state = state.copyWith(error: e, stackTrace: st, isFetching: false, isLoading: false); } } Future fetchMore() async { - print('UnifiedPaginationController: Fetch more'); + quantusDebugPrint('UnifiedPaginationController: Fetch more'); if (state.isFetching || !state.hasMore) return; @@ -121,30 +122,36 @@ class UnifiedPaginationController extends StateNotifier { /// Refresh data silently without showing loading indicators. /// Used for automatic polling to update data in background. Future silentRefresh() async { - print('UnifiedPaginationController: Silent refresh called'); + quantusDebugPrint('UnifiedPaginationController: Silent refresh called'); if (state.isFetching) return; final targetAccountIds = _getAccountIds(); if (targetAccountIds.isEmpty) return; - await _silentFetchFirstPage(targetAccountIds); + state = state.copyWith(isFetching: true); + + try { + await _silentFetchFirstPage(targetAccountIds); + } finally { + state = state.copyWith(isFetching: false); + } } /// Refresh data with loading indicators. /// Used for user-initiated refreshes like pull-to-refresh. Future loadingRefresh() async { - print('UnifiedPaginationController: Loading Refresh'); + quantusDebugPrint('UnifiedPaginationController: Loading Refresh'); // Check connectivity before refreshing final isOnline = ref.read(isOnlineProvider); if (!isOnline) { - print('Skipping refresh - offline'); + quantusDebugPrint('Skipping refresh - offline'); return; } final targetAccountIds = _getAccountIds(); if (targetAccountIds.isEmpty) { - state = PaginationState.initial().copyWith(hasMore: false); + state = PaginationState.initial().copyWith(hasMore: false, isLoading: false); return; } @@ -168,11 +175,10 @@ class UnifiedPaginationController extends StateNotifier { otherOffset: newTransactions.nextOtherOffset, scheduledOffset: newTransactions.nextScheduledOffset, hasMore: newTransactions.hasMore, - error: null, - stackTrace: null, + clearError: true, ); } catch (e, st) { - print('Silent refresh failed: $e, $st'); + quantusDebugPrint('Silent refresh failed: $e, $st'); } } @@ -180,7 +186,7 @@ class UnifiedPaginationController extends StateNotifier { /// refresh. /// Moves the transfer from reversibleTransfers to the top of items list. void updateReversibleTransferToExecuted(String txId, ReversibleTransferStatus newStatus) { - print('Updating reversible transfer to executed: $txId'); + quantusDebugPrint('Updating reversible transfer to executed: $txId'); // Find the reversible transfer with the matching hash final reversibleTransfer = state.scheduledReversibleTransfers @@ -188,7 +194,7 @@ class UnifiedPaginationController extends StateNotifier { .firstOrNull; if (reversibleTransfer == null) { - print('Reversible transfer not found for txId: $txId'); + quantusDebugPrint('Reversible transfer not found for txId: $txId'); return; } @@ -221,13 +227,13 @@ class UnifiedPaginationController extends StateNotifier { scheduledReversibleTransfers: updatedScheduledReversibleTransfers, ); - print('Successfully moved transfer from reversible to executed'); + quantusDebugPrint('Successfully moved transfer from reversible to executed'); } /// Adds a newly found transaction to the top of the history list. /// This is used when a broadcast transaction is found in blockchain history. void addTransactionToHistory(TransactionEvent transaction) { - print('Adding transaction to history: ${transaction.id}'); + quantusDebugPrint('Adding transaction to history: ${transaction.id}'); // Check if transaction already exists to avoid duplicates final existsInOtherTransfers = state.otherTransfers.any((item) => item.id == transaction.id); @@ -236,7 +242,7 @@ class UnifiedPaginationController extends StateNotifier { ); if (existsInOtherTransfers || existsInScheduledReversibleTransfers) { - print('Transaction ${transaction.id} already exists in history'); + quantusDebugPrint('Transaction ${transaction.id} already exists in history'); return; } @@ -250,6 +256,6 @@ class UnifiedPaginationController extends StateNotifier { state = state.copyWith(otherTransfers: updatedOtherTransfers); } - print('Successfully added transaction ${transaction.id} to history'); + quantusDebugPrint('Successfully added transaction ${transaction.id} to history'); } } diff --git a/mobile-app/lib/providers/filtered_all_transactions_provider.dart b/mobile-app/lib/providers/filtered_all_transactions_provider.dart index f6e258af..c61fea9e 100644 --- a/mobile-app/lib/providers/filtered_all_transactions_provider.dart +++ b/mobile-app/lib/providers/filtered_all_transactions_provider.dart @@ -8,6 +8,7 @@ import 'package:resonance_network_wallet/providers/account_id_list_cache.dart'; import 'package:resonance_network_wallet/providers/controllers/unified_pagination_controller.dart'; import 'package:resonance_network_wallet/providers/pending_cancellations_provider.dart'; import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; /// Family provider for filtered pagination controllers. /// @@ -31,11 +32,14 @@ final filteredTransactionsProviderFamily = final pending = ref.watch(pendingTransactionsProvider); final pagination = ref.watch(filteredPaginationControllerProviderFamily(normalizedParams)); - if (pagination.error != null) { - print('FilteredTransactionsProvider: Error: ${pagination.error}'); + if (pagination.error != null && !pagination.hasLoadedChainData) { + quantusDebugPrint('FilteredTransactionsProvider: Error: ${pagination.error}'); return AsyncValue.error(pagination.error!, pagination.stackTrace!); } - if (pagination.isFetching && pagination.otherTransfers.isEmpty) { + if (pagination.error != null) { + quantusDebugPrint('FilteredTransactionsProvider: Load-more error: ${pagination.error}'); + } + if (pagination.isLoading && !pagination.hasLoadedChainData) { return const AsyncValue.loading(); } diff --git a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart index 84a0f43d..8175aea6 100644 --- a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart @@ -29,9 +29,8 @@ class _CreateAccountScreenState extends ConsumerState { List _accounts = []; int _walletIndex = 0; bool _isLoading = false; - String? _error; - bool get _isDisabled => _accountName.text.isEmpty || _isLoading || _error != null; + bool get _isDisabled => _accountName.text.trim().isEmpty || _isLoading; int _walletIndexForActiveAccount(List accounts, DisplayAccount? activeDisplayAccount) { if (activeDisplayAccount is RegularAccount) { @@ -126,7 +125,7 @@ class _CreateAccountScreenState extends ConsumerState { return ScaffoldBase( appBar: V2AppBar(title: l10n.createAccountAppBarTitle), - mainContent: NameField(controller: _accountName, subtitle: l10n.createAccountSubtitle, error: _error), + mainContent: NameField(controller: _accountName, subtitle: l10n.createAccountSubtitle), bottomContent: ScaffoldBaseBottomContent( child: QuantusButton.simple( label: l10n.createAccountButton, diff --git a/mobile-app/lib/v2/screens/accounts/edit_account_screen.dart b/mobile-app/lib/v2/screens/accounts/edit_account_screen.dart index 6bb7ea76..c891c49f 100644 --- a/mobile-app/lib/v2/screens/accounts/edit_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/edit_account_screen.dart @@ -24,6 +24,8 @@ class EditAccountScreenState extends ConsumerState { final _accountsService = AccountsService(); bool _saving = false; + bool get _isDisabled => _controller.text.trim().isEmpty; + @override void initState() { super.initState(); @@ -82,6 +84,7 @@ class EditAccountScreenState extends ConsumerState { label: l10n.editAccountDone, onTap: _save, isLoading: _saving, + isDisabled: _isDisabled, ), ), ); diff --git a/mobile-app/lib/v2/screens/activity/activity_screen.dart b/mobile-app/lib/v2/screens/activity/activity_screen.dart index 264c0ef2..18f01426 100644 --- a/mobile-app/lib/v2/screens/activity/activity_screen.dart +++ b/mobile-app/lib/v2/screens/activity/activity_screen.dart @@ -10,7 +10,6 @@ import 'package:resonance_network_wallet/providers/currency_display_provider.dar import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/services/transaction_service.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; @@ -26,20 +25,42 @@ class ActivityScreen extends ConsumerStatefulWidget { } class _ActivityScreenState extends ConsumerState { - TransactionFilter _filterOption = TransactionFilter.all; + static const _loadMoreThreshold = 200.0; + static const _filterOption = TransactionFilter.all; - void _onFilterOptionChanged(TransactionFilter option) { - if (_filterOption == option) return; - setState(() { - _filterOption = option; - }); + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + + final pos = _scrollController.position; + if (pos.maxScrollExtent <= 0 || pos.pixels < pos.maxScrollExtent - _loadMoreThreshold) return; + + final pagination = ref.read(activeAccountPaginationProvider(_filterOption)); + if (pagination == null || pagination.isFetching || !pagination.hasMore) return; + + readActiveAccountPaginationNotifier(ref, _filterOption)?.fetchMore(); } - String _filterLabel(TransactionFilter filter, AppLocalizations l10n) => switch (filter) { - TransactionFilter.all => l10n.activityFilterAll, - TransactionFilter.send => l10n.activityFilterSend, - TransactionFilter.receive => l10n.activityFilterReceive, - }; + Future _refresh() async { + final pagination = ref.read(activeAccountPaginationProvider(_filterOption)); + if (pagination == null || pagination.isFetching) return; + + await readActiveAccountPaginationNotifier(ref, _filterOption)?.silentRefresh(); + } @override Widget build(BuildContext context) { @@ -49,126 +70,125 @@ class _ActivityScreenState extends ConsumerState { final text = context.themeText; final accountAsync = ref.watch(activeAccountProvider); final txAsync = ref.watch(activeAccountTransactionsProvider(_filterOption)); + final pagination = ref.watch(activeAccountPaginationProvider(_filterOption)); final formatTxAmount = ref.watch(txAmountDisplayProvider); - final filterButtons = TransactionFilter.values - .map( - (e) => _buildFilterButton( - _filterLabel(e, l10n), - onTap: () => _onFilterOptionChanged(e), - isSelected: _filterOption == e, - ), - ) - .toList(); - return ScaffoldBase( appBar: V2AppBar(title: l10n.activityTitle), - mainContent: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(spacing: 12, children: filterButtons), - ), - const SizedBox(height: 40), - Expanded( - child: accountAsync.when( - loading: () => const Center(child: Loader()), - error: (e, _) => Center( - child: Text(l10n.activityError(e.toString()), style: text.detail?.copyWith(color: colors.textError)), + mainContent: accountAsync.when( + loading: () => const Center(child: Loader()), + error: (e, _) => Center( + child: Text(l10n.activityError(e.toString()), style: text.detail?.copyWith(color: colors.textError)), + ), + data: (active) { + if (active == null) { + return Center(child: Text(l10n.activityNoAccount)); + } + return txAsync.when( + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, i) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (i > 0) const SizedBox(height: 32), + const Skeleton(width: 100, height: 24), + const SizedBox(height: 12), + for (var j = 0; j < 3; j++) ...[ + const TxItemSkeleton(), + if (j < 2) Divider(color: colors.txItemSeparator, height: 24), + ], + ], ), - data: (active) { - if (active == null) { - return Center(child: Text(l10n.activityNoAccount)); - } - return txAsync.when( - loading: () => ListView.builder( - itemCount: 3, - itemBuilder: (context, i) => Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + error: (e, _) => Center( + child: Text(l10n.activityError(e.toString()), style: text.detail?.copyWith(color: colors.textError)), + ), + data: (data) { + final txService = ref.read(transactionServiceProvider); + final all = txService.combineAndDeduplicateTransactions( + pendingCancellationIds: data.pendingCancellationIds, + pendingTransactions: data.pendingTransactions, + scheduledReversibleTransfers: data.scheduledReversibleTransfers, + otherTransfers: data.otherTransfers, + ); + if (all.isEmpty) { + return _buildRefreshableContent( + child: LayoutBuilder( + builder: (context, constraints) => ListView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), children: [ - if (i > 0) const SizedBox(height: 32), - const Skeleton(width: 100, height: 24), - const SizedBox(height: 12), - for (var j = 0; j < 3; j++) ...[ - const TxItemSkeleton(), - if (j < 2) Divider(color: colors.txItemSeparator, height: 24), - ], + ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Text( + l10n.activityEmpty, + style: text.paragraph?.copyWith(color: colors.textSecondary), + ), + ), + ), ], ), ), - error: (e, _) => Center( - child: Text( - l10n.activityError(e.toString()), - style: text.detail?.copyWith(color: colors.textError), - ), - ), - data: (data) { - final txService = ref.read(transactionServiceProvider); - final all = txService.combineAndDeduplicateTransactions( - pendingCancellationIds: data.pendingCancellationIds, - pendingTransactions: data.pendingTransactions, - scheduledReversibleTransfers: data.scheduledReversibleTransfers, - otherTransfers: data.otherTransfers, - ); - if (all.isEmpty) { - return Center( - child: Text(l10n.activityEmpty, style: text.paragraph?.copyWith(color: colors.textSecondary)), + ); + } + final grouped = _groupByDate(all, l10n, appLocale.numberFormatLocale); + final showLoadMoreFooter = pagination != null && pagination.isLoading && pagination.hasMore; + + return _buildRefreshableContent( + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: grouped.length + (showLoadMoreFooter ? 1 : 0), + itemBuilder: (context, i) { + if (showLoadMoreFooter && i == grouped.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Loader()), ); } - final grouped = _groupByDate(all, l10n, appLocale.numberFormatLocale); - - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: grouped.length, - itemBuilder: (context, i) { - final group = grouped[i]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (i > 0) const SizedBox(height: 32), - Text(group.label, style: text.receiveLabel?.copyWith(color: colors.textTertiary)), - ...group.transactions.mapIndexed((index, tx) { - final itemData = TxItemData.from(tx, active.account.accountId, colors, l10n); - final isLastItem = index == group.transactions.length - 1; - return buildTxItem( - tx, - itemData, - colors, - text, - l10n, - formattedAmount: formatTxAmount(itemData.amount, isSend: itemData.isSend).primaryAmount, - isLastItem: isLastItem, - onTap: () { - showTransactionDetailSheet(context, tx, active.account.accountId); - }, - ); - }), - ], - ); - }, + + final group = grouped[i]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (i > 0) const SizedBox(height: 32), + Text(group.label, style: text.receiveLabel?.copyWith(color: colors.textTertiary)), + ...group.transactions.mapIndexed((index, tx) { + final itemData = TxItemData.from(tx, active.account.accountId, colors, l10n); + final isLastItem = index == group.transactions.length - 1; + return buildTxItem( + tx, + itemData, + colors, + text, + l10n, + formattedAmount: formatTxAmount(itemData.amount, isSend: itemData.isSend).primaryAmount, + isLastItem: isLastItem, + onTap: () { + showTransactionDetailSheet(context, tx, active.account.accountId); + }, + ); + }), + ], ); }, - ); - }, - ), - ), - ], + ), + ); + }, + ); + }, ), ); } - Widget _buildFilterButton(String label, {bool isSelected = false, required VoidCallback onTap}) { - final variant = isSelected ? ButtonVariant.primary : ButtonVariant.outline; - - return IntrinsicWidth( - child: QuantusButton.simple( - label: label, - variant: variant, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - onTap: onTap, - ), + Widget _buildRefreshableContent({required Widget child}) { + return RefreshIndicator( + onRefresh: _refresh, + color: context.colors.textPrimary, + backgroundColor: context.colors.surface, + child: child, ); } diff --git a/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart b/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart index 2e103720..de3c4b85 100644 --- a/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart +++ b/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart @@ -8,9 +8,7 @@ import 'package:resonance_network_wallet/providers/mining_rewards_provider.dart' import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/mining_rewards_service.dart'; import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/split_card.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; @@ -37,13 +35,14 @@ class MiningRewardsScreen extends ConsumerWidget { _ErrorState(colors: colors, text: text, l10n: l10n, onRetry: () => ref.invalidate(miningRewardsProvider)), ), ], - bottomContent: miningAsync.when( - data: (data) => data.totalBlocks > 0 - ? ScaffoldBaseBottomContent(child: QuantusButton.simple(label: l10n.settingsMiningRedeem, onTap: null)) - : null, - loading: () => null, - error: (err, _) => null, - ), + // TODO: Enable redeem button when it is implemented + // bottomContent: miningAsync.when( + // data: (data) => data.totalBlocks > 0 + // ? ScaffoldBaseBottomContent(child: QuantusButton.simple(label: l10n.settingsMiningRedeem, onTap: null)) + // : null, + // loading: () => null, + // error: (err, _) => null, + // ), ); } } diff --git a/mobile-app/test/models/pagination_state_test.dart b/mobile-app/test/models/pagination_state_test.dart new file mode 100644 index 00000000..d2dfc534 --- /dev/null +++ b/mobile-app/test/models/pagination_state_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonance_network_wallet/models/pagination_state.dart'; + +void main() { + group('PaginationState.copyWith error semantics', () { + final baselineError = Exception('fetch failed'); + final baselineStack = StackTrace.current; + + late PaginationState stateWithError; + + setUp(() { + stateWithError = PaginationState.initial().copyWith(error: baselineError, stackTrace: baselineStack); + }); + + test('omitted error and stackTrace are preserved', () { + final next = stateWithError.copyWith(isFetching: true); + + expect(next.error, same(baselineError)); + expect(next.stackTrace, baselineStack); + expect(next.isFetching, isTrue); + }); + + test('clearError sets both to null', () { + final next = stateWithError.copyWith(clearError: true); + + expect(next.error, isNull); + expect(next.stackTrace, isNull); + }); + + test('error and stackTrace can be set', () { + final newError = Exception('other'); + final newStack = StackTrace.empty; + + final next = stateWithError.copyWith(error: newError, stackTrace: newStack); + + expect(next.error, newError); + expect(next.stackTrace, newStack); + }); + + test('clearError takes precedence over error and stackTrace', () { + final next = stateWithError.copyWith(clearError: true, error: Exception('ignored'), stackTrace: StackTrace.empty); + + expect(next.error, isNull); + expect(next.stackTrace, isNull); + }); + }); +}