1
+import 'package:cake_wallet/src/screens/base_page.dart';
2
+import 'package:cake_wallet/view_model/dev/exchange_provider_logs_view_model.dart';
3
+import 'package:cake_wallet/utils/exchange_provider_logger.dart';
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter/services.dart';
6
+import 'package:flutter_mobx/flutter_mobx.dart';
7
+import 'package:intl/intl.dart';
8
+
9
+class DevExchangeProviderLogsPage extends BasePage {
10
+ final ExchangeProviderLogsViewModel viewModel;
11
+
12
+ DevExchangeProviderLogsPage(this.viewModel) {
13
+ viewModel.loadLogs();
14
+ }
15
+
16
+ @override
17
+ String? get title => "[dev] exchange provider logs";
18
+
19
+ @override
20
+ Widget? trailing(BuildContext context) {
21
+ return IconButton(
22
+ icon: Icon(Icons.refresh),
23
+ onPressed: () => viewModel.refreshLogs(),
24
+ );
25
+ }
26
+
27
+ @override
28
+ Widget body(BuildContext context) {
29
+ return Observer(
30
+ builder: (_) {
31
+ if (viewModel.isLoading) {
32
+ return Center(child: CircularProgressIndicator());
33
+ }
34
+
35
+ if (viewModel.error != null) {
36
+ return Center(child: Text("Error: ${viewModel.error}"));
37
+ }
38
+
39
+ if (viewModel.logs.isEmpty) {
40
+ return Center(
41
+ child: Column(
42
+ mainAxisAlignment: MainAxisAlignment.center,
43
+ children: [
44
+ Text("No exchange provider logs available"),
45
+ SizedBox(height: 16),
46
+ ElevatedButton(
47
+ onPressed: () => viewModel.loadLogs(),
48
+ child: Text("Load Logs"),
49
+ ),
50
+ ],
51
+ ),
52
+ );
53
+ }
54
+
55
+ return Column(
56
+ crossAxisAlignment: CrossAxisAlignment.start,
57
+ children: [
58
+ _StatsCard(viewModel),
59
+ _LogsHeader(viewModel),
60
+ Expanded(
61
+ child: _LogsList(viewModel),
62
+ ),
63
+ _ActionButtons(viewModel),
64
+ ],
65
+ );
66
+ },
67
+ );
68
+ }
69
+}
70
+
71
+class _StatsCard extends StatefulWidget {
72
+ final ExchangeProviderLogsViewModel viewModel;
73
+
74
+ const _StatsCard(this.viewModel);
75
+
76
+ @override
77
+ State<_StatsCard> createState() => _StatsCardState();
78
+}
79
+
80
+class _StatsCardState extends State<_StatsCard> {
81
+ bool _isExpanded = false;
82
+
83
+ @override
84
+ Widget build(BuildContext context) {
85
+ return Card(
86
+ margin: EdgeInsets.all(16),
87
+ child: Column(
88
+ children: [
89
+ ListTile(
90
+ title: Text(
91
+ "Stats",
92
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
93
+ fontSize: 18,
94
+ ),
95
+ ),
96
+ trailing: Icon(
97
+ _isExpanded ? Icons.expand_less : Icons.expand_more,
98
+ ),
99
+ onTap: () {
100
+ setState(() {
101
+ _isExpanded = !_isExpanded;
102
+ });
103
+ },
104
+ ),
105
+ if (_isExpanded) ...[
106
+ Observer(
107
+ builder: (_) => Padding(
108
+ padding: EdgeInsets.fromLTRB(16, 0, 16, 16),
109
+ child: Column(
110
+ crossAxisAlignment: CrossAxisAlignment.start,
111
+ children: [
112
+ Row(
113
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
114
+ children: [
115
+ _StatItem("Total", widget.viewModel.totalLogs.toString()),
116
+ _StatItem("Success", widget.viewModel.successLogs.toString()),
117
+ _StatItem("Errors", widget.viewModel.errorLogs.toString()),
118
+ ],
119
+ ),
120
+ SizedBox(height: 16),
121
+ Text(
122
+ "Logs by Provider:",
123
+ style: Theme.of(context).textTheme.titleMedium,
124
+ ),
125
+ SizedBox(height: 8),
126
+ ...widget.viewModel.logsByProvider.entries.map(
127
+ (entry) => Padding(
128
+ padding: EdgeInsets.symmetric(vertical: 2),
129
+ child: Row(
130
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
131
+ children: [
132
+ Text(entry.key.title),
133
+ Text(entry.value.toString()),
134
+ ],
135
+ ),
136
+ ),
137
+ ),
138
+ ],
139
+ ),
140
+ ),
141
+ ),
142
+ ],
143
+ ],
144
+ ),
145
+ );
146
+ }
147
+}
148
+
149
+class _LogsHeader extends StatelessWidget {
150
+ final ExchangeProviderLogsViewModel viewModel;
151
+
152
+ const _LogsHeader(this.viewModel);
153
+
154
+ @override
155
+ Widget build(BuildContext context) {
156
+ return Padding(
157
+ padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
158
+ child: Row(
159
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
160
+ children: [
161
+ Text(
162
+ "Logs",
163
+ style: Theme.of(context).textTheme.titleMedium,
164
+ ),
165
+ Observer(
166
+ builder: (_) => PopupMenuButton<LogFilter>(
167
+ tooltip: "Filter logs",
168
+ onSelected: (LogFilter filter) {
169
+ viewModel.setFilter(filter);
170
+ },
171
+ itemBuilder: (context) => [
172
+ PopupMenuItem(
173
+ value: LogFilter.all,
174
+ child: Row(
175
+ children: [
176
+ Icon(Icons.list, size: 18),
177
+ SizedBox(width: 8),
178
+ Text("All Logs"),
179
+ ],
180
+ ),
181
+ ),
182
+ PopupMenuItem(
183
+ value: LogFilter.success,
184
+ child: Row(
185
+ children: [
186
+ Icon(Icons.check_circle, size: 18, color: Colors.green),
187
+ SizedBox(width: 8),
188
+ Text("Success Only"),
189
+ ],
190
+ ),
191
+ ),
192
+ PopupMenuItem(
193
+ value: LogFilter.error,
194
+ child: Row(
195
+ children: [
196
+ Icon(Icons.error, size: 18, color: Colors.red),
197
+ SizedBox(width: 8),
198
+ Text("Errors Only"),
199
+ ],
200
+ ),
201
+ ),
202
+ ],
203
+ child: Container(
204
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
205
+ decoration: BoxDecoration(
206
+ border: Border.all(color: Theme.of(context).dividerColor),
207
+ borderRadius: BorderRadius.circular(4),
208
+ ),
209
+ child: Row(
210
+ mainAxisSize: MainAxisSize.min,
211
+ children: [
212
+ Icon(Icons.filter_list, size: 16),
213
+ SizedBox(width: 4),
214
+ Text(_getFilterText(viewModel.currentFilter)),
215
+ Icon(Icons.arrow_drop_down, size: 16),
216
+ ],
217
+ ),
218
+ ),
219
+ ),
220
+ ),
221
+ ],
222
+ ),
223
+ );
224
+ }
225
+
226
+ String _getFilterText(LogFilter filter) {
227
+ switch (filter) {
228
+ case LogFilter.all:
229
+ return "All";
230
+ case LogFilter.success:
231
+ return "Success";
232
+ case LogFilter.error:
233
+ return "Errors";
234
+ }
235
+ }
236
+}
237
+
238
+class _StatItem extends StatelessWidget {
239
+ final String label;
240
+ final String value;
241
+
242
+ const _StatItem(this.label, this.value);
243
+
244
+ @override
245
+ Widget build(BuildContext context) {
246
+ return Column(
247
+ children: [
248
+ Text(
249
+ value,
250
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
251
+ fontWeight: FontWeight.bold,
252
+ ),
253
+ ),
254
+ Text(label),
255
+ ],
256
+ );
257
+ }
258
+}
259
+
260
+class _LogsList extends StatelessWidget {
261
+ final ExchangeProviderLogsViewModel viewModel;
262
+
263
+ const _LogsList(this.viewModel);
264
+
265
+ @override
266
+ Widget build(BuildContext context) {
267
+ return Observer(
268
+ builder: (_) => ListView.builder(
269
+ itemCount: viewModel.filteredLogs.length,
270
+ itemBuilder: (context, index) {
271
+ final log = viewModel.filteredLogs[index];
272
+ return _LogEntryCard(log);
273
+ },
274
+ ),
275
+ );
276
+ }
277
+}
278
+
279
+class _LogEntryCard extends StatelessWidget {
280
+ final ExchangeProviderLogEntry log;
281
+
282
+ const _LogEntryCard(this.log);
283
+
284
+ void _copyToClipboard(BuildContext context, String text) {
285
+ Clipboard.setData(ClipboardData(text: text));
286
+ ScaffoldMessenger.of(context).showSnackBar(
287
+ SnackBar(
288
+ content: Text("Copied to clipboard"),
289
+ duration: Duration(seconds: 2),
290
+ ),
291
+ );
292
+ }
293
+
294
+ @override
295
+ Widget build(BuildContext context) {
296
+ final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss.SSS');
297
+
298
+ return Card(
299
+ margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
300
+ child: ExpansionTile(
301
+ title: Row(
302
+ children: [
303
+ Icon(
304
+ log.isSuccess ? Icons.check_circle : Icons.error,
305
+ color: log.isSuccess ? Colors.green : Colors.red,
306
+ size: 20,
307
+ ),
308
+ SizedBox(width: 8),
309
+ Expanded(
310
+ child: Text(
311
+ "${log.provider.title} - ${log.function}",
312
+ style: Theme.of(context).textTheme.titleMedium,
313
+ ),
314
+ ),
315
+ ],
316
+ ),
317
+ subtitle: Text(
318
+ dateFormat.format(log.timestamp),
319
+ style: Theme.of(context).textTheme.bodySmall,
320
+ ),
321
+ children: [
322
+ Padding(
323
+ padding: EdgeInsets.all(16),
324
+ child: Column(
325
+ crossAxisAlignment: CrossAxisAlignment.start,
326
+ children: [
327
+ if (log.error != null) ...[
328
+ Text(
329
+ "Error:",
330
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
331
+ color: Colors.red,
332
+ ),
333
+ ),
334
+ SizedBox(height: 4),
335
+ Container(
336
+ width: double.infinity,
337
+ padding: EdgeInsets.all(8),
338
+ decoration: BoxDecoration(
339
+ color: Colors.red.shade50,
340
+ borderRadius: BorderRadius.circular(4),
341
+ border: Border.all(color: Colors.red.shade200),
342
+ ),
343
+ child: GestureDetector(
344
+ onTap: () => _copyToClipboard(context, log.error!),
345
+ child: Text(
346
+ log.error!,
347
+ style: TextStyle(
348
+ fontFamily: 'monospace',
349
+ fontSize: 12,
350
+ color: Colors.black,
351
+ ),
352
+ ),
353
+ ),
354
+ ),
355
+ SizedBox(height: 16),
356
+ ],
357
+ if (log.stackTrace != null) ...[
358
+ Text(
359
+ "Stack Trace:",
360
+ style: Theme.of(context).textTheme.titleSmall,
361
+ ),
362
+ SizedBox(height: 4),
363
+ Container(
364
+ width: double.infinity,
365
+ padding: EdgeInsets.all(8),
366
+ decoration: BoxDecoration(
367
+ color: Colors.grey.shade100,
368
+ borderRadius: BorderRadius.circular(4),
369
+ border: Border.all(color: Colors.grey.shade300),
370
+ ),
371
+ child: GestureDetector(
372
+ onTap: () => _copyToClipboard(context, log.stackTrace!),
373
+ child: Text(
374
+ log.stackTrace!,
375
+ style: TextStyle(
376
+ fontFamily: 'monospace',
377
+ fontSize: 10,
378
+ color: Colors.black,
379
+ ),
380
+ ),
381
+ ),
382
+ ),
383
+ SizedBox(height: 16),
384
+ ],
385
+ if (log.callStack != null) ...[
386
+ Text(
387
+ "Call Stack:",
388
+ style: Theme.of(context).textTheme.titleSmall,
389
+ ),
390
+ SizedBox(height: 4),
391
+ Container(
392
+ width: double.infinity,
393
+ padding: EdgeInsets.all(8),
394
+ decoration: BoxDecoration(
395
+ color: Colors.blue.shade50,
396
+ borderRadius: BorderRadius.circular(4),
397
+ border: Border.all(color: Colors.blue.shade200),
398
+ ),
399
+ child: GestureDetector(
400
+ onTap: () => _copyToClipboard(context, log.callStack!),
401
+ child: Text(
402
+ log.callStack!,
403
+ style: TextStyle(
404
+ fontFamily: 'monospace',
405
+ fontSize: 10,
406
+ color: Colors.black,
407
+ ),
408
+ ),
409
+ ),
410
+ ),
411
+ SizedBox(height: 16),
412
+ ],
413
+ if (log.requestData != null) ...[
414
+ Text(
415
+ "Request Data:",
416
+ style: Theme.of(context).textTheme.titleSmall,
417
+ ),
418
+ SizedBox(height: 4),
419
+ Container(
420
+ width: double.infinity,
421
+ padding: EdgeInsets.all(8),
422
+ decoration: BoxDecoration(
423
+ color: Colors.green.shade50,
424
+ borderRadius: BorderRadius.circular(4),
425
+ border: Border.all(color: Colors.green.shade200),
426
+ ),
427
+ child: GestureDetector(
428
+ onTap: () => _copyToClipboard(context, log.requestData.toString()),
429
+ child: Text(
430
+ log.requestData.toString(),
431
+ style: TextStyle(
432
+ fontFamily: 'monospace',
433
+ fontSize: 10,
434
+ color: Colors.black,
435
+ ),
436
+ ),
437
+ ),
438
+ ),
439
+ SizedBox(height: 16),
440
+ ],
441
+ if (log.responseData != null) ...[
442
+ Text(
443
+ "Response Data:",
444
+ style: Theme.of(context).textTheme.titleSmall,
445
+ ),
446
+ SizedBox(height: 4),
447
+ Container(
448
+ width: double.infinity,
449
+ padding: EdgeInsets.all(8),
450
+ decoration: BoxDecoration(
451
+ color: Colors.orange.shade50,
452
+ borderRadius: BorderRadius.circular(4),
453
+ border: Border.all(color: Colors.orange.shade200),
454
+ ),
455
+ child: GestureDetector(
456
+ onTap: () => _copyToClipboard(context, log.responseData.toString()),
457
+ child: Text(
458
+ log.responseData.toString(),
459
+ style: TextStyle(
460
+ fontFamily: 'monospace',
461
+ fontSize: 10,
462
+ color: Colors.black,
463
+ ),
464
+ ),
465
+ ),
466
+ ),
467
+ ],
468
+ ],
469
+ ),
470
+ ),
471
+ ],
472
+ ),
473
+ );
474
+ }
475
+}
476
+
477
+class _ActionButtons extends StatelessWidget {
478
+ final ExchangeProviderLogsViewModel viewModel;
479
+
480
+ const _ActionButtons(this.viewModel);
481
+
482
+ @override
483
+ Widget build(BuildContext context) {
484
+ return Padding(
485
+ padding: EdgeInsets.all(16),
486
+ child: Wrap(
487
+ spacing: 8,
488
+ runSpacing: 8,
489
+ alignment: WrapAlignment.center,
490
+ runAlignment: WrapAlignment.center,
491
+ children: [
492
+ ElevatedButton.icon(
493
+ onPressed: () => _copyLogsAsText(context),
494
+ icon: Icon(Icons.copy, size: 18),
495
+ label: Text("Copy Text"),
496
+ style: ElevatedButton.styleFrom(
497
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
498
+ ),
499
+ ),
500
+ ElevatedButton.icon(
501
+ onPressed: () => _copyLogsAsJson(context),
502
+ icon: Icon(Icons.code, size: 18),
503
+ label: Text("Copy JSON"),
504
+ style: ElevatedButton.styleFrom(
505
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
506
+ ),
507
+ ),
508
+ ElevatedButton.icon(
509
+ onPressed: () => _showClearDialog(context),
510
+ icon: Icon(Icons.clear, size: 18),
511
+ label: Text("Clear"),
512
+ style: ElevatedButton.styleFrom(
513
+ backgroundColor: Colors.red,
514
+ foregroundColor: Colors.white,
515
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
516
+ ),
517
+ ),
518
+ ],
519
+ ),
520
+ );
521
+ }
522
+
523
+ void _copyLogsAsText(BuildContext context) {
524
+ final logsText = viewModel.getLogsAsText();
525
+ Clipboard.setData(ClipboardData(text: logsText));
526
+ ScaffoldMessenger.of(context).showSnackBar(
527
+ SnackBar(content: Text("Logs copied to clipboard")),
528
+ );
529
+ }
530
+
531
+ void _copyLogsAsJson(BuildContext context) {
532
+ final logsJson = viewModel.getLogsAsJson();
533
+ Clipboard.setData(ClipboardData(text: logsJson));
534
+ ScaffoldMessenger.of(context).showSnackBar(
535
+ SnackBar(content: Text("Logs JSON copied to clipboard")),
536
+ );
537
+ }
538
+
539
+ void _showClearDialog(BuildContext context) {
540
+ showDialog(
541
+ context: context,
542
+ builder: (context) => AlertDialog(
543
+ title: Text("Clear Exchange Provider Logs"),
544
+ content: Text(
545
+ "Are you sure you want to clear all exchange provider logs? This action cannot be undone."),
546
+ actions: [
547
+ TextButton(
548
+ onPressed: () => Navigator.of(context).pop(),
549
+ child: Text("Cancel"),
550
+ ),
551
+ TextButton(
552
+ onPressed: () {
553
+ viewModel.clearLogs();
554
+ Navigator.of(context).pop();
555
+ },
556
+ child: Text("Clear", style: TextStyle(color: Colors.red)),
557
+ ),
558
+ ],
559
+ ),
560
+ );
561
+ }
562
+}