paginate desktop recent activity transactions
What changed, and why it matters
This commit adds pagination to the desktop transaction history view in Stack Wallet. It introduces a reusable paginated list widget and refactors the transaction list to use it, grouping CashFusion transactions and switching from unstable unique widget keys to stable keys based on transaction IDs. There is no security-relevant change visible in the diff.
No security action required. This is a UI/UX performance improvement. Routine code review and testing for pagination edge cases (empty list, single item, page count changes) are sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch creates lib/widgets/paginated_list_view.dart, a generic Flutter paginated list with first/previous/next/last controls and a PageItemPosition helper for styling first/last/solo/somewhere items. transaction_v2_list.dart is refactored to extract transaction grouping logic into _processData() and to use PaginatedListView on desktop while keeping a plain ListView on mobile. transaction_v2_list_item.dart replaces UniqueKey() with stable Key(txKeyString) for TransactionCardV2 and ObjectKey(group) for FusionTxGroupCard, which improves widget state stability during pagination and list updates. No cryptographic, networking, storage, or permission changes are present.
Changed components
lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dartlib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dartlib/widgets/paginated_list_view.dartInspect captured patch +283 / −113
diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart
index 9acb8b7..fc4269f 100644
--- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart
+++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart
@@ -18,11 +18,12 @@ import '../../../../models/isar/models/blockchain_data/transaction.dart';
import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../../providers/db/main_db_provider.dart';
import '../../../../providers/global/wallets_provider.dart';
-import '../../../../themes/stack_colors.dart';
import '../../../../utilities/constants.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/crypto_currency/crypto_currency.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart';
import '../../../../widgets/loading_indicator.dart';
+import '../../../../widgets/paginated_list_view.dart';
import '../../sub_widgets/no_transactions_found.dart';
import '../../wallet_view.dart';
import 'fusion_tx_group_card.dart';
@@ -59,6 +60,54 @@ class _TransactionsV2ListState extends ConsumerState<TransactionsV2List> {
);
}
+ List<Object> _processData(List<TransactionV2> transactions) {
+ if (ref.read(pWallets).getWallet(widget.walletId) is! CashFusionInterface) {
+ return transactions;
+ }
+
+ final List<Object> processed = [];
+
+ List<TransactionV2> fusions = [];
+
+ for (int i = 0; i < transactions.length; i++) {
+ final tx = transactions[i];
+
+ if (tx.subType == TransactionSubType.cashFusion) {
+ if (fusions.isNotEmpty) {
+ final prevTime = DateTime.fromMillisecondsSinceEpoch(
+ fusions.last.timestamp * 1000,
+ );
+ final thisTime = DateTime.fromMillisecondsSinceEpoch(
+ tx.timestamp * 1000,
+ );
+
+ if (prevTime.difference(thisTime).inMinutes > 30) {
+ processed.add(FusionTxGroup(fusions));
+ fusions = [tx];
+ continue;
+ }
+ }
+
+ fusions.add(tx);
+ }
+
+ if (i + 1 < transactions.length) {
+ final nextTx = transactions[i + 1];
+ if (nextTx.subType != TransactionSubType.cashFusion &&
+ fusions.isNotEmpty) {
+ processed.add(FusionTxGroup(fusions));
+ fusions = [];
+ }
+ }
+
+ if (tx.subType != TransactionSubType.cashFusion) {
+ processed.add(tx);
+ }
+ }
+
+ return processed;
+ }
+
@override
void initState() {
coin = ref.read(pWallets).getWallet(widget.walletId).info.coin;
@@ -73,11 +122,10 @@ class _TransactionsV2ListState extends ConsumerState<TransactionsV2List> {
value: [widget.walletId],
),
],
- filter:
- ref
- .read(pWallets)
- .getWallet(widget.walletId)
- .transactionFilterOperation,
+ filter: ref
+ .read(pWallets)
+ .getWallet(widget.walletId)
+ .transactionFilterOperation,
sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)],
);
@@ -128,110 +176,58 @@ class _TransactionsV2ListState extends ConsumerState<TransactionsV2List> {
return compare;
});
- final List<Object> _txns = [];
-
- List<TransactionV2> fusions = [];
-
- for (int i = 0; i < _transactions.length; i++) {
- final tx = _transactions[i];
-
- if (tx.subType == TransactionSubType.cashFusion) {
- if (fusions.isNotEmpty) {
- final prevTime = DateTime.fromMillisecondsSinceEpoch(
- fusions.last.timestamp * 1000,
- );
- final thisTime = DateTime.fromMillisecondsSinceEpoch(
- tx.timestamp * 1000,
- );
-
- if (prevTime.difference(thisTime).inMinutes > 30) {
- _txns.add(FusionTxGroup(fusions));
- fusions = [tx];
- continue;
- }
- }
-
- fusions.add(tx);
- }
-
- if (i + 1 < _transactions.length) {
- final nextTx = _transactions[i + 1];
- if (nextTx.subType != TransactionSubType.cashFusion &&
- fusions.isNotEmpty) {
- _txns.add(FusionTxGroup(fusions));
- fusions = [];
- }
- }
-
- if (tx.subType != TransactionSubType.cashFusion) {
- _txns.add(tx);
- }
- }
+ final _txns = _processData(_transactions);
return RefreshIndicator(
onRefresh: () async {
await ref.read(pWallets).getWallet(widget.walletId).refresh();
},
- child:
- Util.isDesktop
- ? ListView.separated(
- shrinkWrap: true,
- itemBuilder: (context, index) {
- BorderRadius? radius;
- if (_txns.length == 1) {
- radius = BorderRadius.circular(
- Constants.size.circularBorderRadius,
- );
- } else if (index == _txns.length - 1) {
- radius = _borderRadiusLast;
- } else if (index == 0) {
- radius = _borderRadiusFirst;
- }
- final tx = _txns[index];
- return TxListItem(tx: tx, coin: coin, radius: radius);
- },
- separatorBuilder: (context, index) {
- return Container(
- width: double.infinity,
- height: 2,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ child: Util.isDesktop
+ ? PaginatedListView(
+ items: _txns,
+ itemBuilder: (context, tx, position) {
+ final radius = switch (position) {
+ PageItemPosition.first => _borderRadiusFirst,
+ PageItemPosition.last => _borderRadiusLast,
+ PageItemPosition.solo => BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ PageItemPosition.somewhere => null,
+ };
+
+ return TxListItem(tx: tx, coin: coin, radius: radius);
+ },
+ )
+ : ListView.builder(
+ itemCount: _txns.length,
+ itemBuilder: (context, index) {
+ BorderRadius? radius;
+ bool shouldWrap = false;
+ if (_txns.length == 1) {
+ radius = BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ );
+ } else if (index == _txns.length - 1) {
+ radius = _borderRadiusLast;
+ shouldWrap = true;
+ } else if (index == 0) {
+ radius = _borderRadiusFirst;
+ }
+ final tx = _txns[index];
+ if (shouldWrap) {
+ return Column(
+ children: [
+ TxListItem(tx: tx, coin: coin, radius: radius),
+ const SizedBox(
+ height: WalletView.navBarHeight + 14,
+ ),
+ ],
);
- },
- itemCount: _txns.length,
- )
- : ListView.builder(
- itemCount: _txns.length,
- itemBuilder: (context, index) {
- BorderRadius? radius;
- bool shouldWrap = false;
- if (_txns.length == 1) {
- radius = BorderRadius.circular(
- Constants.size.circularBorderRadius,
- );
- } else if (index == _txns.length - 1) {
- radius = _borderRadiusLast;
- shouldWrap = true;
- } else if (index == 0) {
- radius = _borderRadiusFirst;
- }
- final tx = _txns[index];
- if (shouldWrap) {
- return Column(
- children: [
- TxListItem(tx: tx, coin: coin, radius: radius),
- const SizedBox(
- height: WalletView.navBarHeight + 14,
- ),
- ],
- );
- } else {
- return TxListItem(tx: tx, coin: coin, radius: radius);
- }
- },
- ),
+ } else {
+ return TxListItem(tx: tx, coin: coin, radius: radius);
+ }
+ },
+ ),
);
}
},
diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart
index a9d0dee..74ac48d 100644
--- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart
+++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart
@@ -49,6 +49,8 @@ class TxListItem extends ConsumerWidget {
)
: [];
+ final txKeyString = _tx.txid + _tx.type.name + _tx.hashCode.toString();
+
if (matchingTrades.isNotEmpty) {
final trade = matchingTrades.first;
return Container(
@@ -60,14 +62,9 @@ class TxListItem extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- TransactionCardV2(key: UniqueKey(), transaction: _tx),
+ TransactionCardV2(key: Key(txKeyString), transaction: _tx),
TradeCard(
- key: Key(
- _tx.txid +
- _tx.type.name +
- _tx.hashCode.toString() +
- trade.uuid,
- ), //
+ key: Key(txKeyString + trade.uuid),
trade: trade,
onTap: () async {
if (Util.isDesktop) {
@@ -160,7 +157,7 @@ class TxListItem extends ConsumerWidget {
child: Breathing(
child: TransactionCardV2(
// this may mess with combined firo transactions
- key: UniqueKey(),
+ key: Key(txKeyString),
transaction: _tx,
),
),
@@ -176,7 +173,7 @@ class TxListItem extends ConsumerWidget {
borderRadius: radius,
),
child: Breathing(
- child: FusionTxGroupCard(key: UniqueKey(), group: group),
+ child: FusionTxGroupCard(key: ObjectKey(group), group: group),
),
);
}
diff --git a/lib/widgets/paginated_list_view.dart b/lib/widgets/paginated_list_view.dart
new file mode 100644
index 0000000..f4c0a32
--- /dev/null
+++ b/lib/widgets/paginated_list_view.dart
@@ -0,0 +1,177 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+import '../themes/stack_colors.dart';
+import '../utilities/assets.dart';
+import 'custom_buttons/app_bar_icon_button.dart';
+
+enum PageItemPosition { first, last, solo, somewhere }
+
+class PaginatedListView<T> extends StatefulWidget {
+ final List<T> items;
+ final Widget Function(BuildContext context, T item, PageItemPosition position)
+ itemBuilder;
+ final int itemsPerPage;
+ final EdgeInsetsGeometry? padding;
+
+ const PaginatedListView({
+ super.key,
+ required this.items,
+ required this.itemBuilder,
+ this.itemsPerPage = 50,
+ this.padding,
+ });
+
+ @override
+ State<PaginatedListView<T>> createState() => _PaginatedListViewState<T>();
+}
+
+class _PaginatedListViewState<T> extends State<PaginatedListView<T>> {
+ int _currentPage = 0;
+ late int _totalPages;
+ late List<T> _currentPageItems;
+
+ void _updatePagination() {
+ _totalPages = (widget.items.length / widget.itemsPerPage).ceil();
+ if (_totalPages == 0) _totalPages = 1;
+
+ if (_currentPage >= _totalPages) {
+ _currentPage = _totalPages - 1;
+ }
+
+ _updateCurrentPageItems();
+ }
+
+ void _updateCurrentPageItems() {
+ final startIndex = _currentPage * widget.itemsPerPage;
+ final endIndex = (startIndex + widget.itemsPerPage).clamp(
+ 0,
+ widget.items.length,
+ );
+ _currentPageItems = widget.items.sublist(startIndex, endIndex);
+ }
+
+ void _goToPage(int page) {
+ if (mounted && page >= 0 && page < _totalPages && page != _currentPage) {
+ setState(() {
+ _currentPage = page;
+ _updateCurrentPageItems();
+ });
+ }
+ }
+
+ void _nextPage() => _goToPage(_currentPage + 1);
+ void _previousPage() => _goToPage(_currentPage - 1);
+ void _firstPage() => _goToPage(0);
+ void _lastPage() => _goToPage(_totalPages - 1);
+
+ @override
+ void initState() {
+ super.initState();
+ _updatePagination();
+ }
+
+ @override
+ void didUpdateWidget(PaginatedListView<T> oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.items != widget.items ||
+ oldWidget.itemsPerPage != widget.itemsPerPage) {
+ _updatePagination();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: [
+ Expanded(
+ child: ListView.separated(
+ itemCount: _currentPageItems.length,
+ separatorBuilder: (context, index) {
+ return Container(
+ width: double.infinity,
+ height: 2,
+ color: Theme.of(context).extension<StackColors>()!.background,
+ );
+ },
+ itemBuilder: (context, index) {
+ final PageItemPosition position;
+ if (_currentPageItems.length == 1) {
+ position = .solo;
+ } else if (index == _currentPageItems.length - 1) {
+ position = .last;
+ } else if (index == 0) {
+ position = .first;
+ } else {
+ position = .somewhere;
+ }
+
+ return widget.itemBuilder(
+ context,
+ _currentPageItems[index],
+ position,
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 10),
+ Row(
+ mainAxisAlignment: .center,
+ children: [
+ IconButton(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.topNavIconPrimary,
+ disabledColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.topNavIconPrimary.withAlpha(100),
+ icon: const Icon(Icons.first_page),
+ onPressed: _currentPage > 0 ? _firstPage : null,
+ tooltip: "First page",
+ ),
+ AppBarIconButton(
+ icon: Transform.flip(
+ flipX: true,
+ child: SvgPicture.asset(
+ Assets.svg.chevronRight,
+ width: 24,
+ height: 24,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary
+ .withAlpha(_currentPage > 0 ? 255 : 100),
+ ),
+ ),
+ tooltip: "Previous page",
+ onPressed: _currentPage > 0 ? _previousPage : null,
+ ),
+ AppBarIconButton(
+ icon: SvgPicture.asset(
+ Assets.svg.chevronRight,
+ width: 24,
+ height: 24,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary
+ .withAlpha(_currentPage < _totalPages - 1 ? 255 : 100),
+ ),
+ tooltip: "Next page",
+ onPressed: _currentPage < _totalPages - 1 ? _nextPage : null,
+ ),
+ IconButton(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.topNavIconPrimary,
+ disabledColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.topNavIconPrimary.withAlpha(100),
+ icon: const Icon(Icons.last_page),
+ onPressed: _currentPage < _totalPages - 1 ? _lastPage : null,
+ tooltip: "Last page",
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.