@marcos_feitoza/personal-finance-frontend-feature-investments 1.1.1 → 1.1.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [1.1.2](https://github.com/MarcosOps/personal-finance-frontend-feature-investments/compare/v1.1.1...v1.1.2) (2025-12-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * refatoração das telas InvestmentAccountScreen, CryptoAccountScreen e RrspSunLifeScreen para o padrão ViewModel com ChangeNotifier ([20419ac](https://github.com/MarcosOps/personal-finance-frontend-feature-investments/commit/20419ac0c38259469c69389496ec7e24c6d95e3b))
7
+
1
8
  ## [1.1.1](https://github.com/MarcosOps/personal-finance-frontend-feature-investments/compare/v1.1.0...v1.1.1) (2025-12-02)
2
9
 
3
10
 
@@ -1,240 +1,123 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:intl/intl.dart';
3
- import 'package:personal_finance_frontend_core_services/services/crypto_service.dart';
4
- import 'package:personal_finance_frontend_core_services/services/transaction_service.dart';
5
- import 'package:personal_finance_frontend_core_ui/widgets/crypto_trade_form.dart';
6
- import 'package:personal_finance_frontend_core_ui/widgets/app_widgets.dart';
7
3
  import 'package:provider/provider.dart';
8
4
  import 'package:personal_finance_frontend_core_services/providers/auth_provider.dart';
9
5
  import 'package:personal_finance_frontend_core_ui/utils/app_dialogs.dart';
6
+ import 'package:personal_finance_frontend_core_ui/widgets/crypto_trade_form.dart';
7
+ import '../viewmodels/crypto_account_viewmodel.dart';
10
8
 
11
- class CryptoAccountScreen extends StatefulWidget {
9
+ class CryptoAccountScreen extends StatelessWidget {
12
10
  final String accountName;
13
11
 
14
- const CryptoAccountScreen({Key? key, required this.accountName}) : super(key: key);
15
-
16
- @override
17
- _CryptoAccountScreenState createState() => _CryptoAccountScreenState();
18
- }
19
-
20
- class _CryptoAccountScreenState extends State<CryptoAccountScreen> {
21
- final TransactionService _transactionService = TransactionService();
22
- final CryptoService _cryptoService = CryptoService();
23
-
24
- List<Map<String, dynamic>> _trades = [];
25
- List<Map<String, dynamic>> _assets = [];
26
- List<Map<String, dynamic>> _portfolioSummary = [];
27
- Map<String, double> _livePrices = {};
28
- double _cashBalance = 0.0;
29
- double _accountTotalValue = 0.0;
30
- double _totalPortfolioBookCost = 0.0;
31
- bool _isLoading = true;
32
- bool _isFetchingPrices = false;
33
- String? _selectedSymbolForFilter;
34
- String? _token;
35
-
36
- @override
37
- void initState() {
38
- super.initState();
39
- WidgetsBinding.instance.addPostFrameCallback((_) {
40
- final authProvider = Provider.of<AuthProvider>(context, listen: false);
41
- setState(() {
42
- _token = authProvider.token;
43
- });
44
- _fetchData();
45
- });
46
- }
47
-
48
- String currencySymbol = r'$';
49
-
50
- String _formatCurrency(double value) {
51
- return '$currencySymbol${value.toStringAsFixed(2)}';
52
- }
53
-
54
- Future<void> _fetchData() async {
55
- if (_token == null) return;
56
- setState(() => _isLoading = true);
57
- try {
58
- final futures = <Future>[
59
- _transactionService.getTrades(investmentAccount: widget.accountName, token: _token),
60
- _transactionService.getAccountBalance(widget.accountName, token: _token),
61
- _transactionService.getAssets(investmentAccount: widget.accountName, token: _token),
62
- _transactionService.getTotalPortfolioBookCost(token: _token), // Fetch total cost
63
- ];
64
- final results = await Future.wait(futures);
65
-
66
- final trades = results[0] as List<Map<String, dynamic>>;
67
- final balance = results[1] as double;
68
- final assets = results[2] as List<Map<String, dynamic>>;
69
- final totalPortfolioBookCost = results[3] as double;
70
-
71
- final summaryData = _calculatePortfolioSummary(trades, totalPortfolioBookCost);
72
-
73
- setState(() {
74
- _trades = trades;
75
- _cashBalance = balance;
76
- _assets = assets;
77
- _portfolioSummary = summaryData['summary'];
78
- _accountTotalValue = summaryData['total_value'] + _cashBalance;
79
- _totalPortfolioBookCost = totalPortfolioBookCost;
80
- });
81
-
82
- if (mounted) await _fetchLivePrices();
83
- } catch (e) {
84
- if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error fetching data: $e')));
85
- } finally {
86
- if (mounted) setState(() => _isLoading = false);
87
- }
88
- }
89
-
90
- Future<void> _fetchLivePrices() async {
91
- if (_portfolioSummary.isEmpty || !mounted) return;
92
- setState(() => _isFetchingPrices = true);
93
-
94
- final idsToFetch = _portfolioSummary.map((p) => p['id_crypto'] as String? ?? p['symbol'] as String).where((s) => s.isNotEmpty).toList();
95
-
96
- if (idsToFetch.isEmpty) {
97
- setState(() => _isFetchingPrices = false);
98
- return;
99
- }
100
-
101
- final livePrices = await _cryptoService.getLiveCryptoPrices(idsToFetch);
102
-
103
- if (!mounted) return;
104
-
105
- double newTotalValue = _cashBalance;
106
- for (final position in _portfolioSummary) {
107
- final shares = double.parse(position['shares'].toString());
108
- final idForLookup = (position['id_crypto'] as String? ?? position['symbol'] as String).toLowerCase();
109
- final livePrice = livePrices[idForLookup];
110
- if (livePrice != null) {
111
- newTotalValue += shares * livePrice;
112
- } else {
113
- newTotalValue += (position['total_cost'] as num?)?.toDouble() ?? 0.0;
114
- }
115
- }
116
-
117
- setState(() {
118
- _livePrices = livePrices;
119
- _accountTotalValue = newTotalValue;
120
- _isFetchingPrices = false;
121
- });
122
- }
123
-
124
- Map<String, dynamic> _calculatePortfolioSummary(List<Map<String, dynamic>> trades, double totalPortfolioBookCost) {
125
- Map<String, dynamic> summary = {};
126
- double accountPortfolioBookCost = 0;
127
-
128
- for (var trade in trades) {
129
- final asset = trade['asset'];
130
- if (asset == null || asset['symbol'] == null) continue;
131
- String symbol = asset['symbol'];
132
- String idCrypto = asset['id_crypto'] ?? symbol;
133
-
134
- double shares = double.parse(trade['shares'].toString());
135
- double price = double.parse(trade['price'].toString());
136
- String tradeType = trade['trade_type'];
137
-
138
- if (!summary.containsKey(symbol)) {
139
- summary[symbol] = {
140
- 'symbol': symbol,
141
- 'id_crypto': idCrypto,
142
- 'name': asset['name'] ?? symbol,
143
- 'shares': 0.0,
144
- 'total_cost': 0.0,
145
- };
146
- }
147
-
148
- if (tradeType == 'buy') {
149
- summary[symbol]['shares'] += shares;
150
- summary[symbol]['total_cost'] += shares * price;
151
- } else if (tradeType == 'sell') {
152
- double originalShares = summary[symbol]['shares'];
153
- if (originalShares > 0) {
154
- double avgPrice = summary[symbol]['total_cost'] / originalShares;
155
- summary[symbol]['total_cost'] -= shares * avgPrice;
156
- }
157
- summary[symbol]['shares'] -= shares;
158
- }
159
- }
160
-
161
- summary.removeWhere((key, value) => value['shares'] < 0.01);
162
- accountPortfolioBookCost = summary.values.fold(0.0, (sum, item) => sum + item['total_cost']);
163
-
164
- List<Map<String, dynamic>> result = [];
165
- summary.forEach((symbol, data) {
166
- double shares = data['shares'];
167
- double totalCost = data['total_cost'];
168
- data['avg_price'] = (shares > 0) ? totalCost / shares : 0.0;
169
- data['account_allocation'] = (accountPortfolioBookCost > 0) ? (totalCost / accountPortfolioBookCost) * 100 : 0.0;
170
- data['stocks_allocation'] = (totalPortfolioBookCost > 0) ? (totalCost / totalPortfolioBookCost) * 100 : 0.0;
171
- result.add(data);
172
- });
173
-
174
- return {'summary': result, 'total_value': accountPortfolioBookCost};
175
- }
176
-
12
+ const CryptoAccountScreen({Key? key, required this.accountName})
13
+ : super(key: key);
177
14
 
178
15
  @override
179
16
  Widget build(BuildContext context) {
180
- return Scaffold(
181
- appBar: AppBar(
182
- title: Text('${widget.accountName} Portfolio'),
183
- actions: [
184
- Center(
185
- child: Padding(
186
- padding: const EdgeInsets.only(right: 16.0),
187
- child: Column(
188
- mainAxisAlignment: MainAxisAlignment.center,
189
- crossAxisAlignment: CrossAxisAlignment.end,
190
- children: [
191
- Text('Total Portfolio: ${_formatCurrency(_accountTotalValue)}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
192
- Text('Account Balance: ${_formatCurrency(_cashBalance)}', style: const TextStyle(fontSize: 12)),
193
- ],
194
- ),
195
- ),
196
- ),
197
- IconButton(icon: const Icon(Icons.refresh), onPressed: _fetchData, tooltip: 'Refresh Data'),
198
- ],
17
+ final authProvider = Provider.of<AuthProvider>(context, listen: false);
18
+
19
+ return ChangeNotifierProvider(
20
+ create: (_) => CryptoAccountViewModel(
21
+ accountName: accountName,
22
+ token: authProvider.token,
199
23
  ),
200
- body: _isLoading
201
- ? const Center(child: CircularProgressIndicator())
202
- : RefreshIndicator(
203
- onRefresh: _fetchData,
204
- child: SingleChildScrollView(
205
- physics: const AlwaysScrollableScrollPhysics(),
206
- padding: const EdgeInsets.all(16.0),
207
- child: Column(
208
- crossAxisAlignment: CrossAxisAlignment.stretch,
209
- children: [
210
- Row(
211
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
24
+ child: Consumer<CryptoAccountViewModel>(
25
+ builder: (context, viewModel, child) {
26
+ return Scaffold(
27
+ appBar: AppBar(
28
+ title: Text('$accountName Portfolio'),
29
+ actions: [
30
+ Center(
31
+ child: Padding(
32
+ padding: const EdgeInsets.only(right: 16.0),
33
+ child: Column(
34
+ mainAxisAlignment: MainAxisAlignment.center,
35
+ crossAxisAlignment: CrossAxisAlignment.end,
212
36
  children: [
213
- Text('Portfolio Summary', style: Theme.of(context).textTheme.titleLarge),
214
- if (_isFetchingPrices) const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.0)),
37
+ Text(
38
+ 'Total Portfolio: ${_formatCurrency(viewModel.accountTotalValue)}',
39
+ style: const TextStyle(
40
+ fontSize: 16, fontWeight: FontWeight.bold),
41
+ ),
42
+ Text(
43
+ 'Account Balance: ${_formatCurrency(viewModel.cashBalance)}',
44
+ style: const TextStyle(fontSize: 12),
45
+ ),
215
46
  ],
216
47
  ),
217
- _buildSinteticoTable(),
218
- const SizedBox(height: 24),
219
- CryptoTradeForm(
220
- accountName: widget.accountName,
221
- portfolioSummary: _portfolioSummary,
222
- onTradeCreated: (_) => _fetchData(),
223
- token: _token),
224
- const SizedBox(height: 24),
225
- Text('Trade History', style: Theme.of(context).textTheme.titleLarge),
226
- const SizedBox(height: 8),
227
- _buildAnaliticoTable(),
228
- ],
48
+ ),
229
49
  ),
230
- ),
50
+ IconButton(
51
+ icon: const Icon(Icons.refresh),
52
+ onPressed: viewModel.fetchData,
53
+ tooltip: 'Refresh Data',
54
+ ),
55
+ ],
231
56
  ),
57
+ body: viewModel.isLoading
58
+ ? const Center(child: CircularProgressIndicator())
59
+ : RefreshIndicator(
60
+ onRefresh: viewModel.fetchData,
61
+ child: SingleChildScrollView(
62
+ physics: const AlwaysScrollableScrollPhysics(),
63
+ padding: const EdgeInsets.all(16.0),
64
+ child: Column(
65
+ crossAxisAlignment: CrossAxisAlignment.stretch,
66
+ children: [
67
+ Row(
68
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
69
+ children: [
70
+ Text('Portfolio Summary',
71
+ style:
72
+ Theme.of(context).textTheme.titleLarge),
73
+ if (viewModel.isFetchingPrices)
74
+ const SizedBox(
75
+ height: 20,
76
+ width: 20,
77
+ child: CircularProgressIndicator(
78
+ strokeWidth: 2.0),
79
+ ),
80
+ ],
81
+ ),
82
+ _buildSinteticoTable(context, viewModel),
83
+ const SizedBox(height: 24),
84
+ CryptoTradeForm(
85
+ accountName: accountName,
86
+ portfolioSummary: viewModel.portfolioSummary,
87
+ onTradeCreated: (_) => viewModel.fetchData(),
88
+ token: viewModel.token,
89
+ ),
90
+ const SizedBox(height: 24),
91
+ Text('Trade History',
92
+ style: Theme.of(context).textTheme.titleLarge),
93
+ const SizedBox(height: 8),
94
+ _buildAnaliticoTable(context, viewModel),
95
+ ],
96
+ ),
97
+ ),
98
+ ),
99
+ );
100
+ },
101
+ ),
232
102
  );
233
103
  }
234
104
 
235
- Widget _buildSinteticoTable() {
236
- if (_portfolioSummary.isEmpty) return const Center(child: Padding(padding: EdgeInsets.all(16.0), child: Text('No positions held.')));
237
-
105
+ String _formatCurrency(double value) {
106
+ String currencySymbol = r'$';
107
+ return '$currencySymbol${value.toStringAsFixed(2)}';
108
+ }
109
+
110
+ Widget _buildSinteticoTable(
111
+ BuildContext context, CryptoAccountViewModel viewModel) {
112
+ if (viewModel.portfolioSummary.isEmpty) {
113
+ return const Center(
114
+ child: Padding(
115
+ padding: EdgeInsets.all(16.0),
116
+ child: Text('No positions held.'),
117
+ ),
118
+ );
119
+ }
120
+
238
121
  return SingleChildScrollView(
239
122
  scrollDirection: Axis.horizontal,
240
123
  child: DataTable(
@@ -251,51 +134,85 @@ class _CryptoAccountScreenState extends State<CryptoAccountScreen> {
251
134
  DataColumn(label: Text('Return')),
252
135
  DataColumn(label: Text('% Return')),
253
136
  ],
254
- rows: _portfolioSummary.map((position) {
137
+ rows: viewModel.portfolioSummary.map((position) {
255
138
  final symbol = position['symbol'] as String;
256
- final idForLookup = (position['id_crypto'] as String? ?? symbol).toLowerCase();
257
- final livePrice = _livePrices[idForLookup];
139
+ final idForLookup =
140
+ (position['id_crypto'] as String? ?? symbol).toLowerCase();
141
+ final livePrice = viewModel.livePrices[idForLookup];
258
142
  final shares = double.parse(position['shares'].toString());
259
143
  final avgPrice = double.parse(position['avg_price'].toString());
260
- final totalPurchased = double.parse(position['total_cost'].toString());
261
- final accountAllocation = (position['account_allocation'] as num?)?.toDouble() ?? 0.0;
262
- final stocksAllocation = (position['stocks_allocation'] as num?)?.toDouble() ?? 0.0;
144
+ final totalPurchased =
145
+ double.parse(position['total_cost'].toString());
146
+ final accountAllocation =
147
+ (position['account_allocation'] as num?)?.toDouble() ?? 0.0;
148
+ final stocksAllocation =
149
+ (position['stocks_allocation'] as num?)?.toDouble() ?? 0.0;
263
150
 
264
151
  double? totalReturnValue, percentageReturn;
265
152
 
266
153
  if (livePrice != null && shares > 0) {
267
154
  final currentMarketValue = livePrice * shares;
268
155
  totalReturnValue = currentMarketValue - totalPurchased;
269
- if (totalPurchased > 0) percentageReturn = (totalReturnValue / totalPurchased) * 100;
156
+ if (totalPurchased > 0) {
157
+ percentageReturn = (totalReturnValue / totalPurchased) * 100;
158
+ }
270
159
  }
271
160
 
272
- final returnColor = (totalReturnValue ?? 0) >= 0 ? Colors.green : Colors.red;
161
+ final returnColor =
162
+ (totalReturnValue ?? 0) >= 0 ? Colors.green : Colors.red;
273
163
 
274
164
  return DataRow(cells: [
275
- DataCell(SizedBox(width: 150, child: Text(position['name'] as String, overflow: TextOverflow.ellipsis))),
165
+ DataCell(SizedBox(
166
+ width: 150,
167
+ child: Text(position['name'] as String,
168
+ overflow: TextOverflow.ellipsis))),
276
169
  DataCell(Text(symbol)),
277
170
  DataCell(Text(shares.toStringAsFixed(6))),
278
171
  DataCell(Text(_formatCurrency(avgPrice))),
279
- DataCell(livePrice != null ? Text(_formatCurrency(livePrice)) : const Text('N/A')),
172
+ DataCell(livePrice != null
173
+ ? Text(_formatCurrency(livePrice))
174
+ : const Text('N/A')),
280
175
  DataCell(Text(_formatCurrency(totalPurchased))),
281
176
  DataCell(Text('${accountAllocation.toStringAsFixed(2)}%')),
282
177
  DataCell(Text('${stocksAllocation.toStringAsFixed(2)}%')),
283
- DataCell(totalReturnValue != null ? Text(_formatCurrency(totalReturnValue), style: TextStyle(color: returnColor)) : const Text('N/A')),
284
- DataCell(percentageReturn != null ? Text('${percentageReturn.toStringAsFixed(2)}%', style: TextStyle(color: returnColor)) : const Text('N/A')),
178
+ DataCell(
179
+ totalReturnValue != null
180
+ ? Text(_formatCurrency(totalReturnValue),
181
+ style: TextStyle(color: returnColor))
182
+ : const Text('N/A'),
183
+ ),
184
+ DataCell(
185
+ percentageReturn != null
186
+ ? Text('${percentageReturn.toStringAsFixed(2)}%',
187
+ style: TextStyle(color: returnColor))
188
+ : const Text('N/A'),
189
+ ),
285
190
  ]);
286
191
  }).toList(),
287
192
  ),
288
193
  );
289
194
  }
290
195
 
291
- Widget _buildAnaliticoTable() {
292
- if (_trades.isEmpty) return const Center(child: Padding(padding: EdgeInsets.all(16.0), child: Text('No trades found.')));
196
+ Widget _buildAnaliticoTable(
197
+ BuildContext context, CryptoAccountViewModel viewModel) {
198
+ if (viewModel.trades.isEmpty) {
199
+ return const Center(
200
+ child: Padding(
201
+ padding: EdgeInsets.all(16.0),
202
+ child: Text('No trades found.'),
203
+ ),
204
+ );
205
+ }
293
206
 
294
- final sortedTrades = List<Map<String, dynamic>>.from(_trades)..sort((a, b) {
295
- try {
296
- return DateTime.parse(b['date'] as String).compareTo(DateTime.parse(a['date'] as String));
297
- } catch (e) { return 0; }
298
- });
207
+ final sortedTrades = List<Map<String, dynamic>>.from(viewModel.trades)
208
+ ..sort((a, b) {
209
+ try {
210
+ return DateTime.parse(b['date'] as String)
211
+ .compareTo(DateTime.parse(a['date'] as String));
212
+ } catch (e) {
213
+ return 0;
214
+ }
215
+ });
299
216
 
300
217
  return DataTable(
301
218
  columns: const [
@@ -305,14 +222,14 @@ class _CryptoAccountScreenState extends State<CryptoAccountScreen> {
305
222
  DataColumn(label: Text('Quantity')),
306
223
  DataColumn(label: Text('Price')),
307
224
  DataColumn(label: Text('Total')),
308
- DataColumn(label: Text('Actions')), // New column
225
+ DataColumn(label: Text('Actions')),
309
226
  ],
310
227
  rows: sortedTrades.map((trade) {
311
228
  final double shares = double.parse(trade['shares'].toString());
312
229
  final double price = double.parse(trade['price'].toString());
313
230
  final double total = shares * price;
314
231
  final tradeDate = DateTime.parse(trade['date'] as String);
315
- final int tradeId = trade['id'] as int; // Get trade ID
232
+ final int tradeId = trade['id'] as int;
316
233
 
317
234
  return DataRow(cells: [
318
235
  DataCell(Text(DateFormat('yyyy-MM-dd').format(tradeDate))),
@@ -321,10 +238,11 @@ class _CryptoAccountScreenState extends State<CryptoAccountScreen> {
321
238
  DataCell(Text(shares.toStringAsFixed(6))),
322
239
  DataCell(Text(_formatCurrency(price))),
323
240
  DataCell(Text(_formatCurrency(total))),
324
- DataCell( // New cell for the delete button
241
+ DataCell(
325
242
  IconButton(
326
243
  icon: const Icon(Icons.delete, color: Colors.red),
327
- onPressed: () => _confirmAndDeleteTrade(tradeId),
244
+ onPressed: () =>
245
+ _confirmAndDeleteTrade(context, viewModel, tradeId),
328
246
  tooltip: 'Delete Trade',
329
247
  ),
330
248
  ),
@@ -333,31 +251,20 @@ class _CryptoAccountScreenState extends State<CryptoAccountScreen> {
333
251
  );
334
252
  }
335
253
 
336
- Future<void> _confirmAndDeleteTrade(int tradeId) async {
337
- final bool? confirm = await AppDialogs.showDeleteConfirmationDialog(context, 'this trade');
254
+ Future<void> _confirmAndDeleteTrade(BuildContext context,
255
+ CryptoAccountViewModel viewModel, int tradeId) async {
256
+ final bool? confirm =
257
+ await AppDialogs.showDeleteConfirmationDialog(context, 'this trade');
338
258
 
339
259
  if (confirm == true) {
340
- if (_token == null) {
260
+ final success = await viewModel.deleteTrade(tradeId);
261
+ if (ScaffoldMessenger.of(context).mounted) {
341
262
  ScaffoldMessenger.of(context).showSnackBar(
342
- const SnackBar(content: Text('Authentication token not available.')),
343
- );
344
- return;
345
- }
346
- try {
347
- final success = await _transactionService.deleteTrade(tradeId, token: _token);
348
- if (success) {
349
- ScaffoldMessenger.of(context).showSnackBar(
350
- const SnackBar(content: Text('Trade deleted successfully.')),
351
- );
352
- _fetchData(); // Refresh data after deletion
353
- } else {
354
- ScaffoldMessenger.of(context).showSnackBar(
355
- const SnackBar(content: Text('Failed to delete trade.')),
356
- );
357
- }
358
- } catch (e) {
359
- ScaffoldMessenger.of(context).showSnackBar(
360
- SnackBar(content: Text('Error deleting trade: $e')),
263
+ SnackBar(
264
+ content: Text(success
265
+ ? 'Trade deleted successfully.'
266
+ : 'Failed to delete trade.'),
267
+ ),
361
268
  );
362
269
  }
363
270
  }