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/providers/active_account_transactions_provider.dart b/mobile-app/lib/providers/active_account_transactions_provider.dart index 0cbbc8f5..bb6e00aa 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? activeAccountPaginationNotifier(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..ebeab996 100644 --- a/mobile-app/lib/providers/all_transactions_provider.dart +++ b/mobile-app/lib/providers/all_transactions_provider.dart @@ -16,7 +16,8 @@ final allTransactionsProvider = Provider>(( final pending = ref.watch(pendingTransactionsProvider); final pagination = ref.watch(paginationControllerProvider); - if (pagination.error != null) { + final hasLoadedChainData = pagination.otherTransfers.isNotEmpty || pagination.scheduledReversibleTransfers.isNotEmpty; + if (pagination.error != null && !hasLoadedChainData) { return AsyncValue.error(pagination.error!, pagination.stackTrace!); } if (pagination.isFetching && pagination.otherTransfers.isEmpty) { diff --git a/mobile-app/lib/providers/filtered_all_transactions_provider.dart b/mobile-app/lib/providers/filtered_all_transactions_provider.dart index f6e258af..ce97ea69 100644 --- a/mobile-app/lib/providers/filtered_all_transactions_provider.dart +++ b/mobile-app/lib/providers/filtered_all_transactions_provider.dart @@ -31,10 +31,15 @@ final filteredTransactionsProviderFamily = final pending = ref.watch(pendingTransactionsProvider); final pagination = ref.watch(filteredPaginationControllerProviderFamily(normalizedParams)); - if (pagination.error != null) { + final hasLoadedChainData = + pagination.otherTransfers.isNotEmpty || pagination.scheduledReversibleTransfers.isNotEmpty; + if (pagination.error != null && !hasLoadedChainData) { print('FilteredTransactionsProvider: Error: ${pagination.error}'); return AsyncValue.error(pagination.error!, pagination.stackTrace!); } + if (pagination.error != null) { + print('FilteredTransactionsProvider: Load-more error: ${pagination.error}'); + } if (pagination.isFetching && pagination.otherTransfers.isEmpty) { return const AsyncValue.loading(); } diff --git a/mobile-app/lib/v2/screens/activity/activity_screen.dart b/mobile-app/lib/v2/screens/activity/activity_screen.dart index 264c0ef2..c9a5e0fc 100644 --- a/mobile-app/lib/v2/screens/activity/activity_screen.dart +++ b/mobile-app/lib/v2/screens/activity/activity_screen.dart @@ -26,7 +26,41 @@ class ActivityScreen extends ConsumerStatefulWidget { } class _ActivityScreenState extends ConsumerState { + static const _loadMoreThreshold = 200.0; + TransactionFilter _filterOption = TransactionFilter.all; + 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.pixels < pos.maxScrollExtent - _loadMoreThreshold) return; + + final pagination = ref.read(activeAccountPaginationProvider(_filterOption)); + if (pagination == null || pagination.isFetching || !pagination.hasMore) return; + + activeAccountPaginationNotifier(ref, _filterOption)?.fetchMore(); + } + + Future _refresh() async { + final pagination = ref.read(activeAccountPaginationProvider(_filterOption)); + if (pagination == null || pagination.isFetching) return; + + await activeAccountPaginationNotifier(ref, _filterOption)?.loadingRefresh(); + } void _onFilterOptionChanged(TransactionFilter option) { if (_filterOption == option) return; @@ -49,6 +83,7 @@ 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 @@ -113,41 +148,77 @@ class _ActivityScreenState extends ConsumerState { otherTransfers: data.otherTransfers, ); if (all.isEmpty) { - return Center( - child: Text(l10n.activityEmpty, style: text.paragraph?.copyWith(color: colors.textSecondary)), + return RefreshIndicator( + onRefresh: _refresh, + color: colors.textPrimary, + backgroundColor: colors.surface, + child: ListView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.3, + child: Center( + child: Text( + l10n.activityEmpty, + style: text.paragraph?.copyWith(color: colors.textSecondary), + ), + ), + ), + ], + ), ); } final grouped = _groupByDate(all, l10n, appLocale.numberFormatLocale); + final showLoadMoreFooter = pagination != null && pagination.isFetching && pagination.hasMore; - 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); - }, - ); - }), - ], - ); - }, + return RefreshIndicator( + onRefresh: _refresh, + color: colors.textPrimary, + backgroundColor: colors.surface, + child: ListView.builder( + key: ValueKey(_filterOption), + 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 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); + }, + ); + }), + ], + ); + }, + ), ); }, ); diff --git a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart index 6b94e35a..9292b3b7 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -54,6 +54,7 @@ class _PosQrScreenState extends ConsumerState { void _startWatching() { final l10n = ref.read(l10nProvider); + final formattingService = ref.read(numberFormattingServiceProvider); final active = ref.read(activeAccountProvider).value; if (active == null) return; @@ -103,6 +104,7 @@ class _PosQrScreenState extends ConsumerState { } }, onError: (e) { + quantusDebugPrint('[PosQr] watch error: $e'); quantusDebugPrint('[PosQr] watch error: $e'); _txWatch.dispose(); _timeoutTimer?.cancel(); @@ -110,6 +112,7 @@ class _PosQrScreenState extends ConsumerState { setState(() { _watching = false; _watchError = ref.read(l10nProvider).posQrConnectionLost; + _watchError = ref.read(l10nProvider).posQrConnectionLost; }); } }, @@ -121,6 +124,7 @@ class _PosQrScreenState extends ConsumerState { setState(() { _watching = false; _watchError = ref.read(l10nProvider).posQrTimedOut; + _watchError = ref.read(l10nProvider).posQrTimedOut; }); } }); @@ -161,6 +165,8 @@ class _PosQrScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final appLocale = ref.watch(selectedAppLocaleProvider); final l10n = ref.watch(l10nProvider); final appLocale = ref.watch(selectedAppLocaleProvider); final colors = context.colors; @@ -175,11 +181,13 @@ class _PosQrScreenState extends ConsumerState { ); return ScaffoldBase( + appBar: V2AppBar(title: _isPaid ? l10n.posQrTitlePaymentReceived : l10n.posQrTitleScanToPay), appBar: V2AppBar(title: _isPaid ? l10n.posQrTitlePaymentReceived : l10n.posQrTitleScanToPay), mainContent: accountAsync.when( loading: () => const Center(child: Loader()), error: (e, _) => Center( child: Text(l10n.posQrError('$e'), style: text.detail?.copyWith(color: colors.textError)), + child: Text(l10n.posQrError('$e'), style: text.detail?.copyWith(color: colors.textError)), ), data: (active) { if (active == null) return Center(child: Text(l10n.posQrNoActiveAccount)); @@ -191,13 +199,17 @@ class _PosQrScreenState extends ConsumerState { }, ), bottomContent: ScaffoldBaseBottomContent(child: _isPaid ? _buildPaidButtons(l10n) : _buildQrButton(l10n)), + bottomContent: ScaffoldBaseBottomContent(child: _isPaid ? _buildPaidButtons(l10n) : _buildQrButton(l10n)), ); } + Widget _buildQrButton(AppLocalizations l10n) { + return QuantusButton.simple(label: l10n.posQrNewCharge, onTap: _newCharge, variant: ButtonVariant.primary); Widget _buildQrButton(AppLocalizations l10n) { return QuantusButton.simple(label: l10n.posQrNewCharge, onTap: _newCharge, variant: ButtonVariant.primary); } + Widget _buildPaidButtons(AppLocalizations l10n) { Widget _buildPaidButtons(AppLocalizations l10n) { final padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 20); @@ -211,11 +223,18 @@ class _PosQrScreenState extends ConsumerState { onTap: _done, variant: ButtonVariant.secondary, ), + child: QuantusButton.simple( + padding: padding, + label: l10n.posQrDone, + onTap: _done, + variant: ButtonVariant.secondary, + ), ), Expanded( child: QuantusButton.simple( padding: padding, label: l10n.posQrNewCharge, + label: l10n.posQrNewCharge, onTap: _newCharge, variant: ButtonVariant.primary, ), @@ -224,6 +243,13 @@ class _PosQrScreenState extends ConsumerState { ); } + Widget _buildPaidContent( + AppLocalizations l10n, + String localeName, + AppColorsV2 colors, + AppTextTheme text, + String amountDisplay, + ) { Widget _buildPaidContent( AppLocalizations l10n, String localeName, @@ -241,6 +267,7 @@ class _PosQrScreenState extends ConsumerState { _buildSuccessCircle(colors), const SizedBox(height: 32), Text( + l10n.posQrAmountReceived(amountDisplay), l10n.posQrAmountReceived(amountDisplay), style: text.smallTitle?.copyWith(color: colors.textLightGray, fontSize: 32, fontWeight: FontWeight.w400), textAlign: TextAlign.center, @@ -248,14 +275,17 @@ class _PosQrScreenState extends ConsumerState { const SizedBox(height: 4), if (_paidAt != null) Text( + _formatPaidAt(_paidAt!, localeName, l10n), _formatPaidAt(_paidAt!, localeName, l10n), style: text.smallParagraph?.copyWith(color: colors.textTertiary, letterSpacing: 0.7), textAlign: TextAlign.center, ), const SizedBox(height: 32), _buildFromSection(l10n, colors, text, formattedAddress), + _buildFromSection(l10n, colors, text, formattedAddress), const Spacer(), _buildExplorerLink(l10n, colors, text), + _buildExplorerLink(l10n, colors, text), const SizedBox(height: 16), ], ); @@ -273,11 +303,13 @@ class _PosQrScreenState extends ConsumerState { ); } + Widget _buildFromSection(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text, String formattedAddress) { Widget _buildFromSection(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text, String formattedAddress) { return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( + l10n.posQrFrom, l10n.posQrFrom, style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), textAlign: TextAlign.center, @@ -310,6 +342,7 @@ class _PosQrScreenState extends ConsumerState { ); } + Widget _buildExplorerLink(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { Widget _buildExplorerLink(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { return GestureDetector( onTap: _openExplorer, @@ -319,11 +352,13 @@ class _PosQrScreenState extends ConsumerState { ), padding: const EdgeInsets.only(bottom: 3), child: Text(l10n.activityDetailViewExplorer, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), + child: Text(l10n.activityDetailViewExplorer, style: text.smallParagraph?.copyWith(color: colors.textTertiary)), ), ); } Widget _buildQrContent( + AppLocalizations l10n, AppLocalizations l10n, PosPaymentRequest request, AppColorsV2 colors, @@ -338,6 +373,8 @@ class _PosQrScreenState extends ConsumerState { const Spacer(), if (!_watching && _watchError != null) _buildErrorSection(l10n, colors, text), if (_watching) _buildWaitingPill(l10n, colors, text), + if (!_watching && _watchError != null) _buildErrorSection(l10n, colors, text), + if (_watching) _buildWaitingPill(l10n, colors, text), const SizedBox(height: 16), ], ); @@ -371,6 +408,7 @@ class _PosQrScreenState extends ConsumerState { ); } + Widget _buildWaitingPill(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { Widget _buildWaitingPill(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { return Container( padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 9), @@ -390,12 +428,15 @@ class _PosQrScreenState extends ConsumerState { ); } + Widget _buildErrorSection(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { Widget _buildErrorSection(AppLocalizations l10n, AppColorsV2 colors, AppTextTheme text) { return Column( children: [ + Text(l10n.posQrNetworkError, style: text.detail?.copyWith(color: colors.textError)), Text(l10n.posQrNetworkError, style: text.detail?.copyWith(color: colors.textError)), const SizedBox(height: 8), QuantusButton.simple( + label: l10n.posQrTryAgain, label: l10n.posQrTryAgain, padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 8), onTap: _startWatching, @@ -405,6 +446,9 @@ class _PosQrScreenState extends ConsumerState { ); } + String _formatPaidAt(DateTime dt, String localeName, AppLocalizations l10n) { + final dateTime = DatetimeFormattingService.formatPaidAt(dt, localeName); + return l10n.posQrPaidAt(dateTime); String _formatPaidAt(DateTime dt, String localeName, AppLocalizations l10n) { final dateTime = DatetimeFormattingService.formatPaidAt(dt, localeName); return l10n.posQrPaidAt(dateTime);