| 1 | import 'dart:io'; |
| 2 | |
| 3 | import 'package:cake_wallet/generated/i18n.dart'; |
| 4 | import 'package:cake_wallet/src/screens/backup/backup_page.dart'; |
| 5 | import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; |
| 6 | import 'package:cake_wallet/utils/share_util.dart'; |
| 7 | import 'package:cake_wallet/utils/show_bar.dart'; |
| 8 | import 'package:cake_wallet/utils/show_pop_up.dart'; |
| 9 | import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; |
| 10 | import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; |
| 11 | import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; |
| 12 | import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; |
| 13 | import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.dart'; |
| 14 | import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; |
| 15 | import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; |
| 16 | import 'package:cw_core/transaction_direction.dart'; |
| 17 | import 'package:file_picker/file_picker.dart'; |
| 18 | import 'package:flutter/material.dart'; |
| 19 | import 'package:intl/intl.dart'; |
| 20 | import 'package:path_provider/path_provider.dart'; |
| 21 | |
| 22 | class CsvExportService { |
| 23 | static const _columns = [ |
| 24 | 'record_type', |
| 25 | 'date_time', |
| 26 | 'type', |
| 27 | 'amount', |
| 28 | 'currency', |
| 29 | 'fee', |
| 30 | 'tx_id', |
| 31 | 'address', |
| 32 | 'status', |
| 33 | 'note', |
| 34 | 'from_amount', |
| 35 | 'from_currency', |
| 36 | 'to_amount', |
| 37 | 'to_currency', |
| 38 | 'trade_id', |
| 39 | 'provider', |
| 40 | 'confirmations', |
| 41 | ]; |
| 42 | |
| 43 | static const _utf8Bom = ''; |
| 44 | |
| 45 | String buildCsvContent(List<ActionListItem> items) { |
| 46 | final buf = StringBuffer(); |
| 47 | buf.write(_utf8Bom); |
| 48 | buf.writeln(_columns.join(',')); |
| 49 | |
| 50 | for (final item in items) { |
| 51 | if (item is DateSectionItem) continue; |
| 52 | |
| 53 | final row = _buildRow(item); |
| 54 | if (row != null) buf.writeln(row); |
| 55 | } |
| 56 | |
| 57 | return buf.toString(); |
| 58 | } |
| 59 | |
| 60 | String? _buildRow(ActionListItem item) { |
| 61 | if (item is TransactionListItem) return _transactionRow(item); |
| 62 | if (item is TradeListItem) return _tradeRow(item); |
| 63 | if (item is OrderListItem) return _orderRow(item); |
| 64 | if (item is AnonpayTransactionListItem) return _anonpayRow(item); |
| 65 | if (item is PayjoinTransactionListItem) return _payjoinRow(item); |
| 66 | return null; |
| 67 | } |
| 68 | |
| 69 | String _transactionRow(TransactionListItem item) { |
| 70 | final tx = item.transaction; |
| 71 | final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing'; |
| 72 | final status = tx.isPending ? 'pending' : 'confirmed'; |
| 73 | |
| 74 | // Prefer tx.to/tx.from; fall back to address lists for chains that don't populate them. |
| 75 | final address = tx.direction == TransactionDirection.incoming |
| 76 | ? (tx.from?.isNotEmpty == true ? tx.from! : (tx.inputAddresses?.firstOrNull ?? '')) |
| 77 | : (tx.to?.isNotEmpty == true ? tx.to! : (tx.outputAddresses?.firstOrNull ?? '')); |
| 78 | |
| 79 | final fee = tx.fee != null && !tx.fee!.isZero ? tx.fee.toString() : ''; |
| 80 | |
| 81 | return _row([ |
| 82 | 'transaction', |
| 83 | _isoDate(tx.date), |
| 84 | type, |
| 85 | tx.amount.toString(), |
| 86 | tx.amount.currency.symbol, |
| 87 | fee, |
| 88 | tx.id, |
| 89 | address, |
| 90 | status, |
| 91 | '', |
| 92 | '', |
| 93 | '', |
| 94 | '', |
| 95 | '', |
| 96 | '', |
| 97 | '', |
| 98 | tx.confirmations.toString(), |
| 99 | ]); |
| 100 | } |
| 101 | |
| 102 | String _tradeRow(TradeListItem item) { |
| 103 | final trade = item.trade; |
| 104 | return _row([ |
| 105 | 'trade', |
| 106 | _isoDate(trade.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)), |
| 107 | 'swap', |
| 108 | '', |
| 109 | '', |
| 110 | trade.fee?.toString() ?? '', |
| 111 | trade.outputTransaction ?? '', |
| 112 | trade.payoutAddress ?? '', |
| 113 | trade.state.title, |
| 114 | trade.memo ?? '', |
| 115 | trade.amount, |
| 116 | trade.from?.name ?? '', |
| 117 | trade.receiveAmount ?? '', |
| 118 | trade.to?.name ?? '', |
| 119 | trade.id, |
| 120 | trade.provider.title, |
| 121 | '', |
| 122 | ]); |
| 123 | } |
| 124 | |
| 125 | String _orderRow(OrderListItem item) { |
| 126 | final order = item.order; |
| 127 | return _row([ |
| 128 | 'order', |
| 129 | _isoDate(order.createdAt), |
| 130 | order.source.title, |
| 131 | order.amountFormatted(), |
| 132 | order.from ?? '', |
| 133 | '', |
| 134 | order.transferId, |
| 135 | '', |
| 136 | order.state.title, |
| 137 | '', |
| 138 | order.amountFormatted(), |
| 139 | order.from ?? '', |
| 140 | order.receiveAmount ?? '', |
| 141 | order.to ?? '', |
| 142 | order.id, |
| 143 | order.providerTitle, |
| 144 | '', |
| 145 | ]); |
| 146 | } |
| 147 | |
| 148 | String _anonpayRow(AnonpayTransactionListItem item) { |
| 149 | final tx = item.transaction; |
| 150 | final amount = tx.fiatAmount?.toString() ?? tx.amountTo?.toString() ?? ''; |
| 151 | final currency = tx.fiatEquiv ?? tx.coinTo; |
| 152 | |
| 153 | return _row([ |
| 154 | 'anonpay', |
| 155 | _isoDate(tx.createdAt), |
| 156 | 'anonymous_payment', |
| 157 | amount, |
| 158 | currency, |
| 159 | '', |
| 160 | tx.invoiceId, |
| 161 | tx.address, |
| 162 | tx.status, |
| 163 | '', |
| 164 | '', |
| 165 | '', |
| 166 | '', |
| 167 | '', |
| 168 | '', |
| 169 | tx.provider, |
| 170 | '', |
| 171 | ]); |
| 172 | } |
| 173 | |
| 174 | String _payjoinRow(PayjoinTransactionListItem item) { |
| 175 | final session = item.session; |
| 176 | final type = session.isSenderSession ? 'send' : 'receive'; |
| 177 | final amount = session.rawAmount ?? '0'; |
| 178 | |
| 179 | return _row([ |
| 180 | 'payjoin', |
| 181 | _isoDate(session.inProgressSince ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)), |
| 182 | type, |
| 183 | amount, |
| 184 | 'BTC', |
| 185 | '', |
| 186 | session.txId ?? '', |
| 187 | '', |
| 188 | item.status, |
| 189 | '', |
| 190 | '', |
| 191 | '', |
| 192 | '', |
| 193 | '', |
| 194 | '', |
| 195 | '', |
| 196 | '', |
| 197 | ]); |
| 198 | } |
| 199 | |
| 200 | String _row(List<String> fields) => fields.map(escapeField).join(','); |
| 201 | |
| 202 | String escapeField(String field) { |
| 203 | if (field.contains(',') || field.contains('"') || field.contains('\n')) { |
| 204 | return '"${field.replaceAll('"', '""')}"'; |
| 205 | } |
| 206 | return field; |
| 207 | } |
| 208 | |
| 209 | String _isoDate(DateTime dt) => dt.toUtc().toIso8601String(); |
| 210 | |
| 211 | Future<void> exportToCsv(List<ActionListItem> items, BuildContext context) async { |
| 212 | final dataItems = |
| 213 | items.whereType<ActionListItem>().where((e) => e is! DateSectionItem).toList(); |
| 214 | |
| 215 | if (dataItems.isEmpty) { |
| 216 | await showBar<void>(context, S.current.csv_nothing_to_export); |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | final now = DateTime.now(); |
| 221 | final fileName = 'cake_wallet_export_${DateFormat('yyyyMMdd_HHmmss').format(now)}.csv'; |
| 222 | |
| 223 | late File csvFile; |
| 224 | |
| 225 | final exportFuture = Future(() async { |
| 226 | final content = buildCsvContent(items); |
| 227 | csvFile = await _writeTempFile(fileName, content); |
| 228 | }); |
| 229 | |
| 230 | showPersistentActionOverlay(context, exportFuture, text: S.current.generating_csv); |
| 231 | await exportFuture; |
| 232 | |
| 233 | if (!context.mounted) return; |
| 234 | |
| 235 | if (Platform.isAndroid) { |
| 236 | _showAndroidExportDialog(context, csvFile, fileName); |
| 237 | } else if (Platform.isIOS) { |
| 238 | await _shareFile(csvFile, fileName, context); |
| 239 | } else { |
| 240 | await _saveFileDesktop(csvFile, fileName); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | void _showAndroidExportDialog(BuildContext context, File csvFile, String fileName) { |
| 245 | showPopUp<void>( |
| 246 | context: context, |
| 247 | builder: (dialogContext) { |
| 248 | return AlertWithTwoActions( |
| 249 | alertTitle: S.current.export_csv, |
| 250 | alertContent: S.current.select_destination, |
| 251 | rightButtonText: S.current.save_to_downloads, |
| 252 | leftButtonText: S.current.share, |
| 253 | actionRightButton: () async { |
| 254 | await _saveToDownloads(fileName, csvFile); |
| 255 | Navigator.of(dialogContext).pop(); |
| 256 | await showBar<void>(context, S.current.file_saved); |
| 257 | await csvFile.delete(); |
| 258 | }, |
| 259 | actionLeftButton: () async { |
| 260 | Navigator.of(dialogContext).pop(); |
| 261 | await _shareFile(csvFile, fileName, context); |
| 262 | }, |
| 263 | ); |
| 264 | }, |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | Future<void> _shareFile(File file, String fileName, BuildContext context) async { |
| 269 | await ShareUtil.shareFile(filePath: file.path, fileName: fileName, context: context); |
| 270 | if (await file.exists()) await file.delete(); |
| 271 | } |
| 272 | |
| 273 | Future<void> _saveFileDesktop(File csvFile, String fileName) async { |
| 274 | final outputPath = await FilePicker.platform.saveFile( |
| 275 | dialogTitle: 'Save CSV export', |
| 276 | fileName: fileName, |
| 277 | lockParentWindow: true, |
| 278 | ); |
| 279 | if (outputPath == null) return; |
| 280 | await csvFile.copy(outputPath); |
| 281 | await csvFile.delete(); |
| 282 | } |
| 283 | |
| 284 | Future<File> _writeTempFile(String fileName, String content) async { |
| 285 | final dir = await getApplicationDocumentsDirectory(); |
| 286 | final file = File('${dir.path}/$fileName'); |
| 287 | if (file.existsSync()) file.deleteSync(); |
| 288 | await file.writeAsString(content, flush: true); |
| 289 | return file; |
| 290 | } |
| 291 | |
| 292 | Future<void> _saveToDownloads(String fileName, File file) async { |
| 293 | if (!Platform.isAndroid) return; |
| 294 | const downloadsPath = '/storage/emulated/0/Download'; |
| 295 | final dest = File('$downloadsPath/$fileName'); |
| 296 | if (dest.existsSync()) dest.deleteSync(); |
| 297 | await file.copy(dest.path); |
| 298 | } |
| 299 | } |