Files
hash-wallet/lib/core/csv_export_service.dart
T
jwinterm dd449f2594 Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
2026-10-24 23:49:29 -04:00

340 lines
9.8 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:hash_wallet/entities/transaction_description.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/backup/backup_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/utils/share_util.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/action_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/date_section_item.dart';
import 'package:hash_wallet/view_model/dashboard/order_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart';
import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart';
import 'package:collection/collection.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
class CsvExportService {
CsvExportService({required this.transactionDescriptionBox});
final Box<TransactionDescription> transactionDescriptionBox;
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 = '';
static const _formulaTriggers = {'=', '+', '-', '@', '\t', '\r'};
static final _plainNumber = RegExp(r'^-?\d+(\.\d+)?$');
String buildCsvContent(List<ActionListItem> items) {
final buf = StringBuffer();
buf.write(_utf8Bom);
buf.writeln(_columns.join(','));
final primaryAddress = _primaryAddressOf(items);
for (final item in items) {
if (item is DateSectionItem) continue;
final row = _buildRow(item, primaryAddress);
if (row != null) buf.writeln(row);
}
return buf.toString();
}
String _primaryAddressOf(List<ActionListItem> items) {
for (final item in items) {
if (item is TransactionListItem) {
return item.balanceViewModel.wallet.walletAddresses.primaryAddress;
}
}
return '';
}
String _noteFor(TransactionListItem item, String primaryAddress) {
final txHash = item.transaction.txHash;
final descriptionKey = '${txHash}_$primaryAddress';
final description = transactionDescriptionBox.values
.firstWhereOrNull((val) => val.id == descriptionKey || val.id == txHash);
return description?.note ?? '';
}
String? _buildRow(ActionListItem item, String primaryAddress) {
if (item is TransactionListItem) return _transactionRow(item, primaryAddress);
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, String primaryAddress) {
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 ?? ''));
final fee = tx.fee != null && !tx.fee!.isZero ? tx.fee.toString() : '';
return _row([
'transaction',
_isoDate(tx.date),
type,
tx.amount.toString(),
tx.amount.currency.symbol,
fee,
tx.id,
address,
status,
_noteFor(item, primaryAddress),
'',
'',
'',
'',
'',
'',
tx.confirmations.toString(),
]);
}
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) {
final value = _neutralizeFormula(field);
if (value.contains(',') ||
value.contains('"') ||
value.contains('\n') ||
value.contains('\r')) {
return '"${value.replaceAll('"', '""')}"';
}
return value;
}
String _neutralizeFormula(String field) {
if (field.isEmpty || !_formulaTriggers.contains(field[0])) return field;
if (_plainNumber.hasMatch(field)) return field;
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);
}
}