@marcos_feitoza/personal-finance-frontend-feature-investments 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.circleci/config.yml +23 -0
- package/.metadata +10 -0
- package/CHANGELOG.md +30 -0
- package/LICENSE +1 -0
- package/README.md +39 -0
- package/analysis_options.yaml +4 -0
- package/lib/personal_finance_frontend_feature_investments.dart +5 -0
- package/lib/screens/crypto_account_screen.dart +381 -0
- package/lib/screens/investment_account_screen.dart +624 -0
- package/lib/screens/rrsp_sun_life_screen.dart +340 -0
- package/package.json +29 -0
- package/pubspec.yaml +59 -0
- package/test/personal_finance_frontend_feature_investments_test.dart +12 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:intl/intl.dart';
|
|
3
|
+
import 'package:personal_finance_frontend_core_services/services/stock_service.dart';
|
|
4
|
+
import 'package:personal_finance_frontend_core_services/services/transaction_service.dart';
|
|
5
|
+
import 'package:personal_finance_frontend_core_ui/widgets/trade_form.dart';
|
|
6
|
+
import 'package:personal_finance_frontend_core_ui/widgets/dividend_log_form.dart';
|
|
7
|
+
import 'package:personal_finance_frontend_core_ui/widgets/app_widgets.dart';
|
|
8
|
+
import 'package:provider/provider.dart';
|
|
9
|
+
import 'package:personal_finance_frontend_core_services/providers/auth_provider.dart';
|
|
10
|
+
|
|
11
|
+
class InvestmentAccountScreen extends StatefulWidget {
|
|
12
|
+
final String accountName;
|
|
13
|
+
final bool showDividends;
|
|
14
|
+
|
|
15
|
+
const InvestmentAccountScreen(
|
|
16
|
+
{Key? key, required this.accountName, this.showDividends = true})
|
|
17
|
+
: super(key: key);
|
|
18
|
+
|
|
19
|
+
@override
|
|
20
|
+
_InvestmentAccountScreenState createState() =>
|
|
21
|
+
_InvestmentAccountScreenState();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class _InvestmentAccountScreenState extends State<InvestmentAccountScreen> {
|
|
25
|
+
final TransactionService _transactionService = TransactionService();
|
|
26
|
+
final StockService _stockService = StockService();
|
|
27
|
+
|
|
28
|
+
List<Map<String, dynamic>> _trades = [];
|
|
29
|
+
List<Map<String, dynamic>> _dividends = [];
|
|
30
|
+
List<Map<String, dynamic>> _assets = [];
|
|
31
|
+
List<Map<String, dynamic>> _portfolioSummary = [];
|
|
32
|
+
Map<String, double> _livePrices = {};
|
|
33
|
+
double _cashBalance = 0.0;
|
|
34
|
+
double _accountTotalValue = 0.0;
|
|
35
|
+
double _totalPortfolioBookCost = 0.0;
|
|
36
|
+
bool _isLoading = true;
|
|
37
|
+
bool _isFetchingPrices = false;
|
|
38
|
+
String? _selectedSymbolForFilter;
|
|
39
|
+
String? _token;
|
|
40
|
+
|
|
41
|
+
@override
|
|
42
|
+
void initState() {
|
|
43
|
+
super.initState();
|
|
44
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
45
|
+
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
46
|
+
setState(() {
|
|
47
|
+
_token = authProvider.token;
|
|
48
|
+
});
|
|
49
|
+
_fetchData();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
String currencySymbol = r'$';
|
|
54
|
+
|
|
55
|
+
String _formatCurrency(double value) {
|
|
56
|
+
return '$currencySymbol${value.toStringAsFixed(2)}';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
String _formatDuration(Duration duration) {
|
|
60
|
+
if (duration.isNegative) return '';
|
|
61
|
+
int days = duration.inDays;
|
|
62
|
+
int years = days ~/ 365;
|
|
63
|
+
days %= 365;
|
|
64
|
+
int months = days ~/ 30;
|
|
65
|
+
days %= 30;
|
|
66
|
+
List<String> parts = [];
|
|
67
|
+
if (years > 0) parts.add('${years}Y');
|
|
68
|
+
if (months > 0) parts.add('${months}M');
|
|
69
|
+
if (days > 0 || (years == 0 && months == 0)) parts.add('${days}D');
|
|
70
|
+
return parts.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Future<void> _fetchData() async {
|
|
74
|
+
if (_token == null) return;
|
|
75
|
+
setState(() => _isLoading = true);
|
|
76
|
+
try {
|
|
77
|
+
final futures = <Future>[
|
|
78
|
+
_transactionService.getTrades(investmentAccount: widget.accountName, token: _token),
|
|
79
|
+
_transactionService.getAccountBalance(widget.accountName, token: _token),
|
|
80
|
+
_transactionService.getAssets(investmentAccount: widget.accountName, token: _token),
|
|
81
|
+
_transactionService.getTotalPortfolioBookCost(token: _token),
|
|
82
|
+
];
|
|
83
|
+
if (widget.showDividends) {
|
|
84
|
+
futures.add(_transactionService.getDividends(
|
|
85
|
+
investmentAccount: widget.accountName, token: _token));
|
|
86
|
+
}
|
|
87
|
+
final results = await Future.wait(futures);
|
|
88
|
+
|
|
89
|
+
final trades = results[0] as List<Map<String, dynamic>>;
|
|
90
|
+
final balance = results[1] as double;
|
|
91
|
+
final assets = results[2] as List<Map<String, dynamic>>;
|
|
92
|
+
final totalPortfolioBookCost = results[3] as double;
|
|
93
|
+
final dividends = widget.showDividends
|
|
94
|
+
? results[4] as List<Map<String, dynamic>>
|
|
95
|
+
: <Map<String, dynamic>>[];
|
|
96
|
+
|
|
97
|
+
final summaryData = _calculatePortfolioSummary(trades, dividends);
|
|
98
|
+
|
|
99
|
+
setState(() {
|
|
100
|
+
_trades = trades;
|
|
101
|
+
_dividends = dividends;
|
|
102
|
+
_cashBalance = balance;
|
|
103
|
+
_assets = assets;
|
|
104
|
+
_portfolioSummary = summaryData['summary'];
|
|
105
|
+
_accountTotalValue = summaryData['total_value'] + _cashBalance;
|
|
106
|
+
_totalPortfolioBookCost = totalPortfolioBookCost;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (mounted) await _fetchLivePrices();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (mounted)
|
|
112
|
+
ScaffoldMessenger.of(context)
|
|
113
|
+
.showSnackBar(SnackBar(content: Text('Error fetching data: $e')));
|
|
114
|
+
} finally {
|
|
115
|
+
if (mounted) setState(() => _isLoading = false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
Future<void> _fetchLivePrices() async {
|
|
120
|
+
if (_portfolioSummary.isEmpty || !mounted) return;
|
|
121
|
+
setState(() => _isFetchingPrices = true);
|
|
122
|
+
|
|
123
|
+
final symbolsToFetch = _portfolioSummary
|
|
124
|
+
.map((p) => p['symbol'] as String)
|
|
125
|
+
.where((s) => s.isNotEmpty)
|
|
126
|
+
.toList();
|
|
127
|
+
|
|
128
|
+
if (symbolsToFetch.isEmpty) {
|
|
129
|
+
setState(() => _isFetchingPrices = false);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
final livePrices = await _stockService.getLivePrices(symbolsToFetch);
|
|
134
|
+
|
|
135
|
+
if (!mounted) return;
|
|
136
|
+
|
|
137
|
+
double newTotalValue = _cashBalance;
|
|
138
|
+
for (final position in _portfolioSummary) {
|
|
139
|
+
final shares = double.parse(position['shares'].toString());
|
|
140
|
+
final livePrice = livePrices[position['symbol']];
|
|
141
|
+
if (livePrice != null) {
|
|
142
|
+
newTotalValue += shares * livePrice;
|
|
143
|
+
} else {
|
|
144
|
+
newTotalValue += (position['total_cost'] as num?)?.toDouble() ?? 0.0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setState(() {
|
|
149
|
+
_livePrices = livePrices;
|
|
150
|
+
_accountTotalValue = newTotalValue;
|
|
151
|
+
_isFetchingPrices = false;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
Map<String, dynamic> _calculatePortfolioSummary(
|
|
156
|
+
List<Map<String, dynamic>> trades, List<Map<String, dynamic>> dividends) {
|
|
157
|
+
Map<String, dynamic> summary = {};
|
|
158
|
+
double accountPortfolioBookCost = 0;
|
|
159
|
+
|
|
160
|
+
Map<String, double> dividendSummary = {};
|
|
161
|
+
if (widget.showDividends) {
|
|
162
|
+
for (var dividend in dividends) {
|
|
163
|
+
final asset = dividend['asset'];
|
|
164
|
+
if (asset != null && asset['symbol'] != null) {
|
|
165
|
+
String symbol = asset['symbol'];
|
|
166
|
+
double amount = double.parse(dividend['amount'].toString());
|
|
167
|
+
dividendSummary.update(symbol, (value) => value + amount,
|
|
168
|
+
ifAbsent: () => amount);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (var trade in trades) {
|
|
174
|
+
final asset = trade['asset'];
|
|
175
|
+
if (asset == null || asset['symbol'] == null) continue;
|
|
176
|
+
String symbol = asset['symbol'];
|
|
177
|
+
|
|
178
|
+
double shares = double.parse(trade['shares'].toString());
|
|
179
|
+
double price = double.parse(trade['price'].toString());
|
|
180
|
+
String tradeType = trade['trade_type'];
|
|
181
|
+
DateTime tradeDate = DateTime.parse(trade['date']);
|
|
182
|
+
|
|
183
|
+
if (!summary.containsKey(symbol)) {
|
|
184
|
+
summary[symbol] = {
|
|
185
|
+
'symbol': symbol,
|
|
186
|
+
'name': asset['name'] ?? symbol,
|
|
187
|
+
'industry': asset['industry'] ?? 'N/A',
|
|
188
|
+
'shares': 0.0,
|
|
189
|
+
'total_cost': 0.0,
|
|
190
|
+
'total_dividends': dividendSummary[symbol] ?? 0.0,
|
|
191
|
+
'first_trade_date': tradeDate,
|
|
192
|
+
'last_activity_date': tradeDate,
|
|
193
|
+
};
|
|
194
|
+
} else {
|
|
195
|
+
if (tradeDate.isBefore(summary[symbol]['first_trade_date'])) {
|
|
196
|
+
summary[symbol]['first_trade_date'] = tradeDate;
|
|
197
|
+
}
|
|
198
|
+
if (tradeDate.isAfter(summary[symbol]['last_activity_date'])) {
|
|
199
|
+
summary[symbol]['last_activity_date'] = tradeDate;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (tradeType == 'buy') {
|
|
204
|
+
summary[symbol]['shares'] += shares;
|
|
205
|
+
summary[symbol]['total_cost'] += shares * price;
|
|
206
|
+
} else if (tradeType == 'sell') {
|
|
207
|
+
double originalShares = summary[symbol]['shares'];
|
|
208
|
+
if (originalShares > 0) {
|
|
209
|
+
double avgPrice = summary[symbol]['total_cost'] / originalShares;
|
|
210
|
+
summary[symbol]['total_cost'] -= shares * avgPrice;
|
|
211
|
+
}
|
|
212
|
+
summary[symbol]['shares'] -= shares;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
summary.removeWhere((key, value) => value['shares'] < 0.01);
|
|
217
|
+
|
|
218
|
+
accountPortfolioBookCost =
|
|
219
|
+
summary.values.fold(0.0, (sum, item) => sum + item['total_cost']);
|
|
220
|
+
|
|
221
|
+
List<Map<String, dynamic>> result = [];
|
|
222
|
+
summary.forEach((symbol, data) {
|
|
223
|
+
double shares = data['shares'];
|
|
224
|
+
double totalCost = data['total_cost'];
|
|
225
|
+
data['avg_price'] = (shares > 0) ? totalCost / shares : 0.0;
|
|
226
|
+
data['account_allocation'] = (accountPortfolioBookCost > 0)
|
|
227
|
+
? (totalCost / accountPortfolioBookCost) * 100
|
|
228
|
+
: 0.0;
|
|
229
|
+
result.add(data);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return {'summary': result, 'total_value': accountPortfolioBookCost};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@override
|
|
236
|
+
Widget build(BuildContext context) {
|
|
237
|
+
return Scaffold(
|
|
238
|
+
appBar: AppBar(
|
|
239
|
+
title: Text('${widget.accountName} Portfolio'),
|
|
240
|
+
actions: [
|
|
241
|
+
Center(
|
|
242
|
+
child: Padding(
|
|
243
|
+
padding: const EdgeInsets.only(right: 16.0),
|
|
244
|
+
child: Column(
|
|
245
|
+
mainAxisAlignment: MainAxisAlignment.center,
|
|
246
|
+
crossAxisAlignment: CrossAxisAlignment.end,
|
|
247
|
+
children: [
|
|
248
|
+
Text(
|
|
249
|
+
'Total Portfolio: ${_formatCurrency(_accountTotalValue)}',
|
|
250
|
+
style: const TextStyle(
|
|
251
|
+
fontSize: 16, fontWeight: FontWeight.bold)),
|
|
252
|
+
Text('Account Balance: ${_formatCurrency(_cashBalance)}',
|
|
253
|
+
style: const TextStyle(fontSize: 12)),
|
|
254
|
+
],
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
IconButton(
|
|
259
|
+
icon: const Icon(Icons.refresh),
|
|
260
|
+
onPressed: _fetchData,
|
|
261
|
+
tooltip: 'Refresh Data'),
|
|
262
|
+
],
|
|
263
|
+
),
|
|
264
|
+
body: _isLoading
|
|
265
|
+
? const Center(child: CircularProgressIndicator())
|
|
266
|
+
: RefreshIndicator(
|
|
267
|
+
onRefresh: _fetchData,
|
|
268
|
+
child: SingleChildScrollView(
|
|
269
|
+
physics: const AlwaysScrollableScrollPhysics(),
|
|
270
|
+
padding: const EdgeInsets.all(16.0),
|
|
271
|
+
child: Column(
|
|
272
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
273
|
+
children: [
|
|
274
|
+
Row(
|
|
275
|
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
276
|
+
children: [
|
|
277
|
+
Text('Portfolio Summary (Sintético)',
|
|
278
|
+
style: Theme.of(context).textTheme.titleLarge),
|
|
279
|
+
if (_isFetchingPrices)
|
|
280
|
+
const SizedBox(
|
|
281
|
+
height: 20,
|
|
282
|
+
width: 20,
|
|
283
|
+
child:
|
|
284
|
+
CircularProgressIndicator(strokeWidth: 2.0)),
|
|
285
|
+
],
|
|
286
|
+
),
|
|
287
|
+
_buildSinteticoTable(),
|
|
288
|
+
const SizedBox(height: 24),
|
|
289
|
+
Row(
|
|
290
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
291
|
+
children: [
|
|
292
|
+
Expanded(
|
|
293
|
+
child: TradeForm(
|
|
294
|
+
accountName: widget.accountName,
|
|
295
|
+
portfolioSummary: _portfolioSummary,
|
|
296
|
+
assets: _assets,
|
|
297
|
+
onTradeCreated: (_) => _fetchData(),
|
|
298
|
+
token: _token,
|
|
299
|
+
)),
|
|
300
|
+
if (widget.showDividends) ...[
|
|
301
|
+
const SizedBox(width: 16),
|
|
302
|
+
Expanded(
|
|
303
|
+
child: DividendLogForm(
|
|
304
|
+
investmentAccount: widget.accountName,
|
|
305
|
+
assets: _assets,
|
|
306
|
+
onDividendLogged: () => _fetchData(),
|
|
307
|
+
token: _token,
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
],
|
|
311
|
+
],
|
|
312
|
+
),
|
|
313
|
+
const SizedBox(height: 24),
|
|
314
|
+
Text('Trade History (Analítico)',
|
|
315
|
+
style: Theme.of(context).textTheme.titleLarge),
|
|
316
|
+
const SizedBox(height: 8),
|
|
317
|
+
_buildAnaliticoFilter(),
|
|
318
|
+
const SizedBox(height: 8),
|
|
319
|
+
_buildAnaliticoTable(),
|
|
320
|
+
],
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
Widget _buildAnaliticoFilter() {
|
|
328
|
+
final symbols =
|
|
329
|
+
_portfolioSummary.map((p) => p['symbol'] as String).toSet().toList();
|
|
330
|
+
return Padding(
|
|
331
|
+
padding: const EdgeInsets.only(bottom: 8.0),
|
|
332
|
+
child: AppDropdown<String?>(
|
|
333
|
+
value: _selectedSymbolForFilter,
|
|
334
|
+
hint: 'Filter by Symbol',
|
|
335
|
+
items: [
|
|
336
|
+
const DropdownMenuItem<String?>(
|
|
337
|
+
value: null, child: Text('All Symbols')),
|
|
338
|
+
...symbols.map((symbol) =>
|
|
339
|
+
DropdownMenuItem<String?>(value: symbol, child: Text(symbol))),
|
|
340
|
+
],
|
|
341
|
+
onChanged: (String? newValue) =>
|
|
342
|
+
setState(() => _selectedSymbolForFilter = newValue),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Widget _buildSinteticoTable() {
|
|
348
|
+
if (_portfolioSummary.isEmpty)
|
|
349
|
+
return const Center(
|
|
350
|
+
child: Padding(
|
|
351
|
+
padding: EdgeInsets.all(16.0),
|
|
352
|
+
child: Text('No positions held.')));
|
|
353
|
+
|
|
354
|
+
return SingleChildScrollView(
|
|
355
|
+
scrollDirection: Axis.horizontal,
|
|
356
|
+
child: DataTable(
|
|
357
|
+
columnSpacing: 24.0,
|
|
358
|
+
columns: [
|
|
359
|
+
const DataColumn(label: Text('Symbol')),
|
|
360
|
+
const DataColumn(label: Text('Age')),
|
|
361
|
+
const DataColumn(label: Text('Purchased')),
|
|
362
|
+
const DataColumn(label: Text('Shares')),
|
|
363
|
+
const DataColumn(label: Text('Avg Price')),
|
|
364
|
+
const DataColumn(label: Text('Live')),
|
|
365
|
+
const DataColumn(label: Text('Purchased')),
|
|
366
|
+
const DataColumn(label: Text('Allocation')),
|
|
367
|
+
const DataColumn(label: Text('Stocks Allocation')),
|
|
368
|
+
const DataColumn(label: Text('Return')),
|
|
369
|
+
const DataColumn(label: Text('% Return')),
|
|
370
|
+
const DataColumn(label: Text('Book Cost')),
|
|
371
|
+
if (widget.showDividends) ...[
|
|
372
|
+
const DataColumn(label: Text('Dividends')),
|
|
373
|
+
const DataColumn(label: Text('Return + Div')),
|
|
374
|
+
const DataColumn(label: Text('Book Cost + Div')),
|
|
375
|
+
const DataColumn(label: Text('% Return Div')),
|
|
376
|
+
],
|
|
377
|
+
],
|
|
378
|
+
rows: _portfolioSummary.map((position) {
|
|
379
|
+
final symbol = position['symbol'] as String;
|
|
380
|
+
final livePrice = _livePrices[symbol];
|
|
381
|
+
final shares = double.parse(position['shares'].toString());
|
|
382
|
+
final avgPrice = double.parse(position['avg_price'].toString());
|
|
383
|
+
final totalPurchased =
|
|
384
|
+
double.parse(position['total_cost'].toString());
|
|
385
|
+
final accountAllocation =
|
|
386
|
+
(position['account_allocation'] as num?)?.toDouble() ?? 0.0;
|
|
387
|
+
final stocksAllocation = (_totalPortfolioBookCost > 0)
|
|
388
|
+
? (totalPurchased / _totalPortfolioBookCost) * 100
|
|
389
|
+
: 0.0;
|
|
390
|
+
final totalDividends =
|
|
391
|
+
(position['total_dividends'] as num?)?.toDouble() ?? 0.0;
|
|
392
|
+
final firstActivity = position['first_trade_date'] as DateTime?;
|
|
393
|
+
final positionAge = firstActivity != null
|
|
394
|
+
? DateTime.now().difference(firstActivity)
|
|
395
|
+
: null;
|
|
396
|
+
|
|
397
|
+
double? totalReturnValue,
|
|
398
|
+
percentageReturn,
|
|
399
|
+
bookCost,
|
|
400
|
+
returnPlusDividend,
|
|
401
|
+
bookCostPlusDividend,
|
|
402
|
+
percentageReturnDividend;
|
|
403
|
+
|
|
404
|
+
if (livePrice != null && shares > 0) {
|
|
405
|
+
final currentMarketValue = livePrice * shares;
|
|
406
|
+
totalReturnValue = currentMarketValue - totalPurchased;
|
|
407
|
+
bookCost = currentMarketValue;
|
|
408
|
+
if (totalPurchased > 0)
|
|
409
|
+
percentageReturn = (totalReturnValue / totalPurchased) * 100;
|
|
410
|
+
if (widget.showDividends) {
|
|
411
|
+
returnPlusDividend = totalReturnValue + totalDividends;
|
|
412
|
+
bookCostPlusDividend = bookCost + totalDividends;
|
|
413
|
+
if (totalPurchased > 0)
|
|
414
|
+
percentageReturnDividend =
|
|
415
|
+
(returnPlusDividend / totalPurchased) * 100;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
final returnColor =
|
|
420
|
+
(totalReturnValue ?? 0) >= 0 ? Colors.green : Colors.red;
|
|
421
|
+
final returnWithDivColor =
|
|
422
|
+
(returnPlusDividend ?? 0) >= 0 ? Colors.green : Colors.red;
|
|
423
|
+
|
|
424
|
+
return DataRow(cells: [
|
|
425
|
+
DataCell(Text(symbol)),
|
|
426
|
+
DataCell(Text(
|
|
427
|
+
positionAge != null ? _formatDuration(positionAge) : 'N/A')),
|
|
428
|
+
DataCell(Text(position['last_activity_date'] != null
|
|
429
|
+
? DateFormat('yyyy-MM-dd')
|
|
430
|
+
.format(position['last_activity_date'])
|
|
431
|
+
: 'N/A')),
|
|
432
|
+
DataCell(Text(shares.toStringAsFixed(4))),
|
|
433
|
+
DataCell(Text(_formatCurrency(avgPrice))),
|
|
434
|
+
DataCell(livePrice != null
|
|
435
|
+
? Text(_formatCurrency(livePrice))
|
|
436
|
+
: const Text('N/A')),
|
|
437
|
+
DataCell(Text(_formatCurrency(totalPurchased))),
|
|
438
|
+
DataCell(Text('${accountAllocation.toStringAsFixed(2)}%')),
|
|
439
|
+
DataCell(Text('${stocksAllocation.toStringAsFixed(2)}%')),
|
|
440
|
+
DataCell(totalReturnValue != null
|
|
441
|
+
? Text(_formatCurrency(totalReturnValue),
|
|
442
|
+
style: TextStyle(color: returnColor))
|
|
443
|
+
: const Text('N/A')),
|
|
444
|
+
DataCell(percentageReturn != null
|
|
445
|
+
? Text('${percentageReturn.toStringAsFixed(2)}%',
|
|
446
|
+
style: TextStyle(color: returnColor))
|
|
447
|
+
: const Text('N/A')),
|
|
448
|
+
DataCell(bookCost != null
|
|
449
|
+
? Text(_formatCurrency(bookCost))
|
|
450
|
+
: const Text('N/A')),
|
|
451
|
+
if (widget.showDividends) ...[
|
|
452
|
+
DataCell(Text(_formatCurrency(totalDividends))),
|
|
453
|
+
DataCell(returnPlusDividend != null
|
|
454
|
+
? Text(_formatCurrency(returnPlusDividend),
|
|
455
|
+
style: TextStyle(color: returnWithDivColor))
|
|
456
|
+
: const Text('N/A')),
|
|
457
|
+
DataCell(bookCostPlusDividend != null
|
|
458
|
+
? Text(_formatCurrency(bookCostPlusDividend))
|
|
459
|
+
: const Text('N/A')),
|
|
460
|
+
DataCell(percentageReturnDividend != null
|
|
461
|
+
? Text('${percentageReturnDividend.toStringAsFixed(2)}%',
|
|
462
|
+
style: TextStyle(color: returnWithDivColor))
|
|
463
|
+
: const Text('N/A')),
|
|
464
|
+
]
|
|
465
|
+
]);
|
|
466
|
+
}).toList(),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
Widget _buildAnaliticoTable() {
|
|
472
|
+
if (_trades.isEmpty)
|
|
473
|
+
return const Center(
|
|
474
|
+
child: Padding(
|
|
475
|
+
padding: EdgeInsets.all(16.0), child: Text('No trades found.')));
|
|
476
|
+
|
|
477
|
+
final List<Map<String, dynamic>> filteredTrades =
|
|
478
|
+
_selectedSymbolForFilter == null
|
|
479
|
+
? _trades
|
|
480
|
+
: _trades
|
|
481
|
+
.where((trade) =>
|
|
482
|
+
trade['asset']?['symbol'] == _selectedSymbolForFilter)
|
|
483
|
+
.toList();
|
|
484
|
+
|
|
485
|
+
if (filteredTrades.isEmpty)
|
|
486
|
+
return const Center(
|
|
487
|
+
child: Padding(
|
|
488
|
+
padding: EdgeInsets.all(16.0),
|
|
489
|
+
child: Text('No trades match the selected symbol.')));
|
|
490
|
+
|
|
491
|
+
final sortedTrades = List<Map<String, dynamic>>.from(filteredTrades)
|
|
492
|
+
..sort((a, b) {
|
|
493
|
+
final idA = int.tryParse(a['id']?.toString() ?? '');
|
|
494
|
+
final idB = int.tryParse(b['id']?.toString() ?? '');
|
|
495
|
+
if (idA != null && idB != null) return idB.compareTo(idA);
|
|
496
|
+
try {
|
|
497
|
+
return DateTime.parse(b['date'] as String)
|
|
498
|
+
.compareTo(DateTime.parse(a['date'] as String));
|
|
499
|
+
} catch (e) {
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return DataTable(
|
|
505
|
+
columns: const [
|
|
506
|
+
DataColumn(label: Text('Date')),
|
|
507
|
+
DataColumn(label: Text('Trade Age')),
|
|
508
|
+
DataColumn(label: Text('Symbol')),
|
|
509
|
+
DataColumn(label: Text('Type')),
|
|
510
|
+
DataColumn(label: Text('Shares')),
|
|
511
|
+
DataColumn(label: Text('Price')),
|
|
512
|
+
DataColumn(label: Text('Live')),
|
|
513
|
+
DataColumn(label: Text('Total')),
|
|
514
|
+
DataColumn(label: Text('Return')),
|
|
515
|
+
DataColumn(label: Text('% Return')),
|
|
516
|
+
DataColumn(label: Text('Book Cost')),
|
|
517
|
+
DataColumn(label: Text('Actions')), // New column for actions
|
|
518
|
+
],
|
|
519
|
+
rows: sortedTrades.map((trade) {
|
|
520
|
+
final double shares = double.parse(trade['shares'].toString());
|
|
521
|
+
final double price = double.parse(trade['price'].toString());
|
|
522
|
+
final double total = shares * price;
|
|
523
|
+
final tradeDate = DateTime.parse(trade['date'] as String);
|
|
524
|
+
final tradeAge = DateTime.now().difference(tradeDate);
|
|
525
|
+
final symbol = trade['asset']?['symbol'] ?? '';
|
|
526
|
+
final livePrice = _livePrices[symbol];
|
|
527
|
+
final int tradeId = trade['id'] as int; // Get trade ID
|
|
528
|
+
|
|
529
|
+
double? tradeReturnValue, tradePercentageReturn, tradeBookCost;
|
|
530
|
+
|
|
531
|
+
if (livePrice != null && shares > 0 && trade['trade_type'] == 'buy') {
|
|
532
|
+
final currentMarketValue = livePrice * shares;
|
|
533
|
+
tradeReturnValue = currentMarketValue - total;
|
|
534
|
+
tradeBookCost = currentMarketValue;
|
|
535
|
+
if (total > 0)
|
|
536
|
+
tradePercentageReturn = (tradeReturnValue / total) * 100;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
final returnColor =
|
|
540
|
+
(tradeReturnValue ?? 0) >= 0 ? Colors.green : Colors.red;
|
|
541
|
+
|
|
542
|
+
return DataRow(cells: [
|
|
543
|
+
DataCell(Text(DateFormat('yyyy-MM-dd').format(tradeDate))),
|
|
544
|
+
DataCell(Text(_formatDuration(tradeAge))),
|
|
545
|
+
DataCell(Text(symbol)),
|
|
546
|
+
DataCell(Text(trade['trade_type'] ?? 'N/A')),
|
|
547
|
+
DataCell(Text(shares.toString())),
|
|
548
|
+
DataCell(Text(_formatCurrency(price))),
|
|
549
|
+
DataCell(livePrice != null
|
|
550
|
+
? Text(_formatCurrency(livePrice))
|
|
551
|
+
: const Text('N/A')),
|
|
552
|
+
DataCell(Text(_formatCurrency(total))),
|
|
553
|
+
DataCell(tradeReturnValue != null
|
|
554
|
+
? Text(_formatCurrency(tradeReturnValue),
|
|
555
|
+
style: TextStyle(color: returnColor))
|
|
556
|
+
: const Text('N/A')),
|
|
557
|
+
DataCell(tradePercentageReturn != null
|
|
558
|
+
? Text('${tradePercentageReturn.toStringAsFixed(2)}%',
|
|
559
|
+
style: TextStyle(color: returnColor))
|
|
560
|
+
: const Text('N/A')),
|
|
561
|
+
DataCell(tradeBookCost != null
|
|
562
|
+
? Text(_formatCurrency(tradeBookCost),
|
|
563
|
+
style: TextStyle(color: returnColor))
|
|
564
|
+
: const Text('N/A')),
|
|
565
|
+
DataCell(
|
|
566
|
+
IconButton(
|
|
567
|
+
icon: const Icon(Icons.delete, color: Colors.red),
|
|
568
|
+
onPressed: () => _confirmAndDeleteTrade(tradeId),
|
|
569
|
+
tooltip: 'Delete Trade',
|
|
570
|
+
),
|
|
571
|
+
),
|
|
572
|
+
]);
|
|
573
|
+
}).toList(),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
Future<void> _confirmAndDeleteTrade(int tradeId) async {
|
|
578
|
+
final bool? confirm = await showDialog<bool>(
|
|
579
|
+
context: context,
|
|
580
|
+
builder: (BuildContext context) {
|
|
581
|
+
return AlertDialog(
|
|
582
|
+
title: const Text('Confirm Deletion'),
|
|
583
|
+
content: const Text('Are you sure you want to delete this trade? This action cannot be undone.'),
|
|
584
|
+
actions: <Widget>[
|
|
585
|
+
TextButton(
|
|
586
|
+
onPressed: () => Navigator.of(context).pop(false),
|
|
587
|
+
child: const Text('Cancel'),
|
|
588
|
+
),
|
|
589
|
+
TextButton(
|
|
590
|
+
onPressed: () => Navigator.of(context).pop(true),
|
|
591
|
+
child: const Text('Delete'),
|
|
592
|
+
),
|
|
593
|
+
],
|
|
594
|
+
);
|
|
595
|
+
},
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
if (confirm == true) {
|
|
599
|
+
if (_token == null) {
|
|
600
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
601
|
+
const SnackBar(content: Text('Authentication token not available.')),
|
|
602
|
+
);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
final success = await _transactionService.deleteTrade(tradeId, token: _token);
|
|
607
|
+
if (success) {
|
|
608
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
609
|
+
const SnackBar(content: Text('Trade deleted successfully.')),
|
|
610
|
+
);
|
|
611
|
+
_fetchData(); // Refresh data after deletion
|
|
612
|
+
} else {
|
|
613
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
614
|
+
const SnackBar(content: Text('Failed to delete trade.')),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
} catch (e) {
|
|
618
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
619
|
+
SnackBar(content: Text('Error deleting trade: $e')),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|