mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
Allow Exporting transaction history to a CSV file (#3230)
* Allow Exporting transaction history to a CSV file * fix: improve safety for share dialog bounds and navigation pop
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/src/screens/backup/backup_page.dart';
|
||||
import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
|
||||
import 'package:cake_wallet/utils/share_util.dart';
|
||||
import 'package:cake_wallet/utils/show_bar.dart';
|
||||
import 'package:cake_wallet/utils/show_pop_up.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/action_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/date_section_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/order_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart';
|
||||
import 'package:cw_core/transaction_direction.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class CsvExportService {
|
||||
static const _columns = [
|
||||
'record_type',
|
||||
'date_time',
|
||||
'type',
|
||||
'amount',
|
||||
'currency',
|
||||
'fee',
|
||||
'tx_id',
|
||||
'address',
|
||||
'status',
|
||||
'note',
|
||||
'from_amount',
|
||||
'from_currency',
|
||||
'to_amount',
|
||||
'to_currency',
|
||||
'trade_id',
|
||||
'provider',
|
||||
'confirmations',
|
||||
];
|
||||
|
||||
static const _utf8Bom = '';
|
||||
|
||||
String buildCsvContent(List<ActionListItem> items) {
|
||||
final buf = StringBuffer();
|
||||
buf.write(_utf8Bom);
|
||||
buf.writeln(_columns.join(','));
|
||||
|
||||
for (final item in items) {
|
||||
if (item is DateSectionItem) continue;
|
||||
|
||||
final row = _buildRow(item);
|
||||
if (row != null) buf.writeln(row);
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
String? _buildRow(ActionListItem item) {
|
||||
if (item is TransactionListItem) return _transactionRow(item);
|
||||
if (item is TradeListItem) return _tradeRow(item);
|
||||
if (item is OrderListItem) return _orderRow(item);
|
||||
if (item is AnonpayTransactionListItem) return _anonpayRow(item);
|
||||
if (item is PayjoinTransactionListItem) return _payjoinRow(item);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _transactionRow(TransactionListItem item) {
|
||||
final tx = item.transaction;
|
||||
final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing';
|
||||
final status = tx.isPending ? 'pending' : 'confirmed';
|
||||
|
||||
// Prefer tx.to/tx.from; fall back to address lists for chains that don't populate them.
|
||||
final address = tx.direction == TransactionDirection.incoming
|
||||
? (tx.from?.isNotEmpty == true
|
||||
? tx.from!
|
||||
: (tx.inputAddresses?.firstOrNull ?? ''))
|
||||
: (tx.to?.isNotEmpty == true
|
||||
? tx.to!
|
||||
: (tx.outputAddresses?.firstOrNull ?? ''));
|
||||
|
||||
// Split "0.2769 LTC" → amount="0.2769", currency="LTC"
|
||||
final (amount, currency) = _splitAmountCurrency(tx.amountFormatted());
|
||||
|
||||
// fee is stored as (inputAmount - totalOutputAmount) which is negative for incoming
|
||||
// transactions — only include it when the raw value is a sensible positive fee.
|
||||
final fee = tx.fee != null && tx.fee! > 0 ? (tx.feeFormatted() ?? '') : '';
|
||||
final (feeAmount, _) = fee.isNotEmpty ? _splitAmountCurrency(fee) : ('', '');
|
||||
|
||||
return _row([
|
||||
'transaction',
|
||||
_isoDate(tx.date),
|
||||
type,
|
||||
amount,
|
||||
currency,
|
||||
feeAmount,
|
||||
tx.id,
|
||||
address,
|
||||
status,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
tx.confirmations.toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Splits a formatted amount like "0.2769 LTC" into ("0.2769", "LTC").
|
||||
/// If there is no trailing symbol the whole string is returned as the amount.
|
||||
(String, String) _splitAmountCurrency(String formatted) {
|
||||
final trimmed = formatted.trim();
|
||||
final idx = trimmed.lastIndexOf(' ');
|
||||
if (idx == -1) return (trimmed, '');
|
||||
return (trimmed.substring(0, idx), trimmed.substring(idx + 1));
|
||||
}
|
||||
|
||||
String _tradeRow(TradeListItem item) {
|
||||
final trade = item.trade;
|
||||
return _row([
|
||||
'trade',
|
||||
_isoDate(trade.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
|
||||
'swap',
|
||||
'',
|
||||
'',
|
||||
trade.fee?.toString() ?? '',
|
||||
trade.outputTransaction ?? '',
|
||||
trade.payoutAddress ?? '',
|
||||
trade.state.title,
|
||||
trade.memo ?? '',
|
||||
trade.amount,
|
||||
trade.from?.name ?? '',
|
||||
trade.receiveAmount ?? '',
|
||||
trade.to?.name ?? '',
|
||||
trade.id,
|
||||
trade.provider.title,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
String _orderRow(OrderListItem item) {
|
||||
final order = item.order;
|
||||
return _row([
|
||||
'order',
|
||||
_isoDate(order.createdAt),
|
||||
order.source.title,
|
||||
order.amountFormatted(),
|
||||
order.from ?? '',
|
||||
'',
|
||||
order.transferId,
|
||||
'',
|
||||
order.state.title,
|
||||
'',
|
||||
order.amountFormatted(),
|
||||
order.from ?? '',
|
||||
order.receiveAmount ?? '',
|
||||
order.to ?? '',
|
||||
order.id,
|
||||
order.providerTitle,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
String _anonpayRow(AnonpayTransactionListItem item) {
|
||||
final tx = item.transaction;
|
||||
final amount = tx.fiatAmount?.toString() ?? tx.amountTo?.toString() ?? '';
|
||||
final currency = tx.fiatEquiv ?? tx.coinTo;
|
||||
|
||||
return _row([
|
||||
'anonpay',
|
||||
_isoDate(tx.createdAt),
|
||||
'anonymous_payment',
|
||||
amount,
|
||||
currency,
|
||||
'',
|
||||
tx.invoiceId,
|
||||
tx.address,
|
||||
tx.status,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
tx.provider,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
String _payjoinRow(PayjoinTransactionListItem item) {
|
||||
final session = item.session;
|
||||
final type = session.isSenderSession ? 'send' : 'receive';
|
||||
final amount = session.rawAmount ?? '0';
|
||||
|
||||
return _row([
|
||||
'payjoin',
|
||||
_isoDate(session.inProgressSince ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)),
|
||||
type,
|
||||
amount,
|
||||
'BTC',
|
||||
'',
|
||||
session.txId ?? '',
|
||||
'',
|
||||
item.status,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
String _row(List<String> fields) => fields.map(escapeField).join(',');
|
||||
|
||||
String escapeField(String field) {
|
||||
if (field.contains(',') || field.contains('"') || field.contains('\n')) {
|
||||
return '"${field.replaceAll('"', '""')}"';
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
String _isoDate(DateTime dt) => dt.toUtc().toIso8601String();
|
||||
|
||||
Future<void> exportToCsv(List<ActionListItem> items, BuildContext context) async {
|
||||
final dataItems = items.whereType<ActionListItem>().where((e) => e is! DateSectionItem).toList();
|
||||
|
||||
if (dataItems.isEmpty) {
|
||||
await showBar<void>(context, S.current.csv_nothing_to_export);
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final fileName = 'cake_wallet_export_${DateFormat('yyyyMMdd_HHmmss').format(now)}.csv';
|
||||
|
||||
late File csvFile;
|
||||
|
||||
final exportFuture = Future(() async {
|
||||
final content = buildCsvContent(items);
|
||||
csvFile = await _writeTempFile(fileName, content);
|
||||
});
|
||||
|
||||
showPersistentActionOverlay(context, exportFuture, text: S.current.generating_csv);
|
||||
await exportFuture;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
_showAndroidExportDialog(context, csvFile, fileName);
|
||||
} else if (Platform.isIOS) {
|
||||
await _shareFile(csvFile, fileName, context);
|
||||
} else {
|
||||
await _saveFileDesktop(csvFile, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAndroidExportDialog(BuildContext context, File csvFile, String fileName) {
|
||||
showPopUp<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertWithTwoActions(
|
||||
alertTitle: S.current.export_csv,
|
||||
alertContent: S.current.select_destination,
|
||||
rightButtonText: S.current.save_to_downloads,
|
||||
leftButtonText: S.current.share,
|
||||
actionRightButton: () async {
|
||||
await _saveToDownloads(fileName, csvFile);
|
||||
Navigator.of(dialogContext).pop();
|
||||
await showBar<void>(context, S.current.file_saved);
|
||||
await csvFile.delete();
|
||||
},
|
||||
actionLeftButton: () async {
|
||||
Navigator.of(dialogContext).pop();
|
||||
await _shareFile(csvFile, fileName, context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareFile(File file, String fileName, BuildContext context) async {
|
||||
await ShareUtil.shareFile(filePath: file.path, fileName: fileName, context: context);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
|
||||
Future<void> _saveFileDesktop(File csvFile, String fileName) async {
|
||||
final outputPath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: 'Save CSV export',
|
||||
fileName: fileName,
|
||||
lockParentWindow: true,
|
||||
);
|
||||
if (outputPath == null) return;
|
||||
await csvFile.copy(outputPath);
|
||||
await csvFile.delete();
|
||||
}
|
||||
|
||||
Future<File> _writeTempFile(String fileName, String content) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
await file.writeAsString(content, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> _saveToDownloads(String fileName, File file) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
const downloadsPath = '/storage/emulated/0/Download';
|
||||
final dest = File('$downloadsPath/$fileName');
|
||||
if (dest.existsSync()) dest.deleteSync();
|
||||
await file.copy(dest.path);
|
||||
}
|
||||
}
|
||||
@@ -763,7 +763,7 @@ class _NewSendPageState extends State<NewSendPage> {
|
||||
conditionToDetermineIfToUse2FA: check,
|
||||
onAuthSuccess: (value) async {
|
||||
if (value) {
|
||||
if (!widget.mode.popOnConfirmation) {
|
||||
if (!widget.mode.popOnConfirmation && Navigator.canPop(context)) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cake_wallet/core/csv_export_service.dart';
|
||||
import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/new-ui/widgets/line_tab_switcher.dart';
|
||||
import 'package:cake_wallet/routes.dart';
|
||||
@@ -24,6 +25,9 @@ class AssetsTopBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final settingsButtonText = _getSettingsButtonText();
|
||||
final hasTokenSettingsButton = settingsButtonText != null;
|
||||
// Reuse the exact same detection logic as _getSettingsButtonText so the
|
||||
// export button always matches whether the Filters button is visible.
|
||||
final isHistoryTab = settingsButtonText == S.current.filters;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -39,8 +43,33 @@ class AssetsTopBar extends StatelessWidget {
|
||||
selectedTab: selectedTab,
|
||||
)
|
||||
else SizedBox.shrink(),
|
||||
Opacity(
|
||||
opacity: hasTokenSettingsButton ? 1 : 0,
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (isHistoryTab)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
CsvExportService().exportToCsv(dashboardViewModel.items, context),
|
||||
child: Semantics(
|
||||
label: S.of(context).export_csv,
|
||||
button: true,
|
||||
child: Container(
|
||||
height: 36,
|
||||
width: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.file_download_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: hasTokenSettingsButton ? 1 : 0,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (tabs[selectedTab] == S.of(context).assets) {
|
||||
@@ -48,40 +77,41 @@ class AssetsTopBar extends StatelessWidget {
|
||||
Routes.homeSettings,
|
||||
arguments: dashboardViewModel.balanceViewModel,
|
||||
);
|
||||
} else if(tabs[selectedTab] == S.of(context).history) {
|
||||
} else if (tabs[selectedTab] == S.of(context).history) {
|
||||
showPopUp<void>(
|
||||
context: context,
|
||||
builder: (context) => FilterWidget(filterItems: dashboardViewModel.filterItems),
|
||||
builder: (context) =>
|
||||
FilterWidget(filterItems: dashboardViewModel.filterItems),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999999),
|
||||
),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999999),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
CakeImageWidget(
|
||||
imageUrl: "assets/new-ui/options_slider.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.primary, BlendMode.srcIn)),
|
||||
Text(
|
||||
settingsButtonText??"",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
settingsButtonText ?? "",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cake_wallet/bitcoin/bitcoin.dart';
|
||||
import 'package:cake_wallet/core/csv_export_service.dart';
|
||||
import 'package:cake_wallet/entities/balance_display_mode.dart';
|
||||
import 'package:cake_wallet/order/order_source_description.dart';
|
||||
import 'package:cake_wallet/src/screens/dashboard/widgets/anonpay_transaction_row.dart';
|
||||
@@ -65,6 +66,8 @@ class TransactionsPage extends StatelessWidget {
|
||||
}),
|
||||
HeaderRow(
|
||||
dashboardViewModel: dashboardViewModel,
|
||||
onExportCsv: () =>
|
||||
CsvExportService().exportToCsv(dashboardViewModel.items, context),
|
||||
key: ValueKey('transactions_page_header_row_key'),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -5,9 +5,10 @@ import 'package:cake_wallet/generated/i18n.dart';
|
||||
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
|
||||
|
||||
class HeaderRow extends StatelessWidget {
|
||||
HeaderRow({required this.dashboardViewModel, super.key});
|
||||
HeaderRow({required this.dashboardViewModel, this.onExportCsv, super.key});
|
||||
|
||||
final DashboardViewModel dashboardViewModel;
|
||||
final VoidCallback? onExportCsv;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -28,31 +29,62 @@ class HeaderRow extends StatelessWidget {
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
Semantics(
|
||||
container: true,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('transactions_page_header_row_transaction_filter_button_key'),
|
||||
onTap: () {
|
||||
showPopUp<void>(
|
||||
context: context,
|
||||
builder: (context) => FilterWidget(filterItems: dashboardViewModel.filterItems),
|
||||
);
|
||||
},
|
||||
child: Semantics(
|
||||
label: 'Transaction Filter',
|
||||
button: true,
|
||||
enabled: true,
|
||||
child: Container(
|
||||
height: 36,
|
||||
width: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
Row(
|
||||
children: [
|
||||
if (onExportCsv != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
key: ValueKey('transactions_page_header_row_export_csv_button_key'),
|
||||
onTap: onExportCsv,
|
||||
child: Semantics(
|
||||
label: S.of(context).export_csv,
|
||||
button: true,
|
||||
enabled: true,
|
||||
child: Container(
|
||||
height: 36,
|
||||
width: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.file_download_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
container: true,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('transactions_page_header_row_transaction_filter_button_key'),
|
||||
onTap: () {
|
||||
showPopUp<void>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
FilterWidget(filterItems: dashboardViewModel.filterItems),
|
||||
);
|
||||
},
|
||||
child: Semantics(
|
||||
label: 'Transaction Filter',
|
||||
button: true,
|
||||
enabled: true,
|
||||
child: Container(
|
||||
height: 36,
|
||||
width: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: filterIcon,
|
||||
),
|
||||
),
|
||||
child: filterIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -33,8 +33,20 @@ class ShareUtil {
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox) {
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
return box!.localToGlobal(Offset.zero) & box.size;
|
||||
final rect = renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||
// iOS requires sharePositionOrigin to lie fully within the source view's
|
||||
// bounds; clip to the screen so an offset/oversized page rect doesn't spill
|
||||
// past the edge and trigger a PlatformException on iPad.
|
||||
final screen = Offset.zero & MediaQuery.of(context).size;
|
||||
final clipped = rect.intersect(screen);
|
||||
if (clipped.isEmpty) {
|
||||
return Rect.zero;
|
||||
}
|
||||
return clipped;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user