dev
dart 299 lines 8.39 KB
Raw
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 }