@marcos_feitoza/personal-finance-frontend-feature-dashboard 1.1.2 → 1.2.1

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,24 @@
1
+ ## [1.2.1](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/compare/v1.2.0...v1.2.1) (2026-01-30)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Reorder no mobile ([1342ce5](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/commit/1342ce51d9f0c1b4ea2fc684dfe756d1835b252e))
7
+ * Web responsivo (mobile/tablet/resize) ([9ad887a](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/commit/9ad887ae839e6bb70edfe2f50877f00dc36d4851))
8
+
9
+ # [1.2.0](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/compare/v1.1.2...v1.2.0) (2026-01-29)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * add revoke token ([9d4febb](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/commit/9d4febb18f932dae3c853656cb08ba9e3ab53ca5))
15
+
16
+
17
+ ### Features
18
+
19
+ * add useer profile ([f3e410a](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/commit/f3e410aea7852115e5be40199d3cd24e95205812))
20
+ * new AppTheme process ([5538715](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/commit/55387150f8271f898e8309dfc0c3f1dc8ee66661))
21
+
1
22
  ## [1.1.2](https://github.com/MarcosOps/personal-finance-frontend-feature-dashboard/compare/v1.1.1...v1.1.2) (2025-12-06)
2
23
 
3
24
 
@@ -0,0 +1,19 @@
1
+ import 'package:flutter/widgets.dart';
2
+
3
+ /// A lightweight definition of a dashboard card/widget.
4
+ ///
5
+ /// The goal is to make the dashboard layout data-driven so we can:
6
+ /// - reorder widgets (mobile MVP)
7
+ /// - persist order locally
8
+ /// - later allow hide/show and desktop grid reorder
9
+ class DashboardWidgetDefinition {
10
+ final String id;
11
+ final String title;
12
+ final WidgetBuilder builder;
13
+
14
+ const DashboardWidgetDefinition({
15
+ required this.id,
16
+ required this.title,
17
+ required this.builder,
18
+ });
19
+ }
@@ -1,5 +1,4 @@
1
1
  import 'package:flutter/material.dart';
2
- import 'package:flutter/services.dart';
3
2
  import 'package:personal_finance_frontend_core_ui/widgets/salary_form.dart';
4
3
  import 'package:personal_finance_frontend_core_ui/widgets/expense_form.dart';
5
4
  import 'package:personal_finance_frontend_core_ui/widgets/investment_form.dart';
@@ -14,24 +13,20 @@ import 'package:personal_finance_frontend_feature_reports/screens/move_money_rep
14
13
  import 'package:personal_finance_frontend_feature_investments/screens/investment_account_screen.dart';
15
14
  import 'package:personal_finance_frontend_feature_investments/screens/rrsp_sun_life_screen.dart';
16
15
  import 'package:personal_finance_frontend_feature_investments/screens/crypto_account_screen.dart';
17
- import 'package:personal_finance_frontend_feature_charts/viewmodels/salary_chart_viewmodel.dart';
18
- import 'package:personal_finance_frontend_feature_reports/viewmodels/expense_report_viewmodel.dart';
19
- import 'package:personal_finance_frontend_core_services/services/transaction_service.dart';
20
- import 'package:personal_finance_frontend_core_ui/utils/currency_input_formatter.dart';
21
- import 'package:personal_finance_frontend_core_services/models/payment_method.dart';
22
16
  import 'package:intl/intl.dart';
23
17
  import 'package:personal_finance_frontend_feature_management/screens/reconciliation_screen.dart';
24
18
  import 'package:personal_finance_frontend_core_ui/widgets/app_widgets.dart';
25
19
  import 'package:personal_finance_frontend_core_ui/utils/theme_notifier.dart';
20
+ import 'package:personal_finance_frontend_core_ui/utils/app_responsive.dart';
26
21
  import 'package:personal_finance_frontend_core_services/providers/auth_provider.dart';
27
22
  import 'package:personal_finance_frontend_core_ui/widgets/user_profile_avatar.dart';
23
+ import 'package:personal_finance_frontend_feature_profile/screens/profile_screen.dart';
24
+ import '../viewmodels/dashboard_viewmodel.dart';
25
+ import 'package:personal_finance_frontend_core_ui/utils/currency_input_formatter.dart';
26
+ import 'package:personal_finance_frontend_core_ui/theme/app_colors.dart';
28
27
 
29
- class HomeScreen extends StatefulWidget {
30
- const HomeScreen({Key? key}) : super(key: key);
31
-
32
- @override
33
- _HomeScreenState createState() => _HomeScreenState();
34
- }
28
+ import '../models/dashboard_widget.dart';
29
+ import '../utils/dashboard_layout_storage.dart';
35
30
 
36
31
  final String currencySymbol = r'$';
37
32
 
@@ -39,131 +34,377 @@ String _formatCurrency(double value) {
39
34
  final format = NumberFormat.currency(
40
35
  symbol: currencySymbol,
41
36
  decimalDigits: 2,
42
- locale: 'en_US', // garante vírgula de milhar e ponto decimal no padrão US
37
+ locale: 'en_US',
43
38
  );
44
39
  return format.format(value);
45
40
  }
46
41
 
42
+ class HomeScreen extends StatefulWidget {
43
+ const HomeScreen({Key? key}) : super(key: key);
44
+
45
+ @override
46
+ State<HomeScreen> createState() => _HomeScreenState();
47
+ }
48
+
47
49
  class _HomeScreenState extends State<HomeScreen> {
48
- final _salaryFormKey = GlobalKey<FormState>();
49
- final _expenseFormKey = GlobalKey<FormState>();
50
- final _investmentFormKey = GlobalKey<FormState>();
51
-
52
- final _rbcCashController = TextEditingController();
53
- final _wealthsimpleCashController = TextEditingController();
54
- final _rbcCardController = TextEditingController();
55
- final _bmoCardController = TextEditingController();
56
-
57
- final TransactionService _transactionService = TransactionService();
58
- double _totalIncome = 0.0;
59
- double _totalExpenses = 0.0;
60
- double _rbcUnpaidExpenses = 0.0;
61
- double _bmoUnpaidExpenses = 0.0;
62
-
63
- List<Map<String, dynamic>> _cashTransactions = [];
64
- List<Map<String, dynamic>> _rbcTransactions = [];
65
- List<Map<String, dynamic>> _bmoTransactions = [];
66
- List<Map<String, dynamic>> _investments = [];
67
- Map<String, double> _accountBalances = {};
68
-
69
- bool _isLoading = true;
70
- String? _selectedMenu;
71
-
72
- final List<String> _investmentAccountNames = [
73
- 'TFSA',
74
- 'RRSP Wealthsimple',
75
- 'Non-Registered',
76
- 'Crypto'
77
- ];
50
+ final DashboardLayoutStorage _layoutStorage = DashboardLayoutStorage();
51
+ List<String>? _mobileOrder;
52
+ bool _mobileOrderLoaded = false;
78
53
 
79
54
  @override
80
55
  void initState() {
81
56
  super.initState();
82
- // Fetch data after the first frame is built
83
- WidgetsBinding.instance.addPostFrameCallback((_) => _fetchData());
84
- }
85
-
86
- @override
87
- void dispose() {
88
- _rbcCashController.dispose();
89
- _wealthsimpleCashController.dispose();
90
- _rbcCardController.dispose();
91
- _bmoCardController.dispose();
92
- super.dispose();
57
+ _loadMobileOrder();
93
58
  }
94
59
 
95
- Future<void> _fetchData() async {
60
+ Future<void> _loadMobileOrder() async {
61
+ final saved = await _layoutStorage.loadOrder();
96
62
  if (!mounted) return;
97
- final authProvider = Provider.of<AuthProvider>(context, listen: false);
98
- final token = authProvider.token;
99
-
100
63
  setState(() {
101
- _isLoading = true;
64
+ _mobileOrder = saved;
65
+ _mobileOrderLoaded = true;
102
66
  });
103
- try {
104
- // Fetch all balances, including the definitive cash balance from the backend
105
- final cashBalanceFuture =
106
- _transactionService.getAccountBalance('CASH', token: token);
107
- final investmentBalanceFutures = _investmentAccountNames
108
- .map((name) =>
109
- _transactionService.getAccountBalance(name, token: token))
110
- .toList();
111
-
112
- final otherFutures = <Future<dynamic>>[
113
- _transactionService.getSummaryByPaymentMethod(token: token),
114
- _transactionService.fetchTransactions(
115
- paymentMethod: 'CASH', limit: 10, token: token),
116
- _transactionService.fetchTransactions(
117
- paymentMethod: 'RBC', limit: 10, token: token),
118
- _transactionService.fetchTransactions(
119
- paymentMethod: 'BMO', limit: 10, token: token),
120
- _transactionService.getInvestments(token: token),
121
- ];
122
-
123
- // Await all futures
124
- final allInvestmentBalances = await Future.wait(investmentBalanceFutures);
125
- final cashBalance = await cashBalanceFuture;
126
- final otherResults = await Future.wait(otherFutures);
127
-
128
- // Safely build the account balances map
129
- _accountBalances = {'CASH': cashBalance};
130
- for (int i = 0; i < _investmentAccountNames.length; i++) {
131
- _accountBalances[_investmentAccountNames[i]] = allInvestmentBalances[i];
67
+ }
68
+
69
+ List<DashboardWidgetDefinition> _defaultMobileWidgets(
70
+ BuildContext context,
71
+ DashboardViewModel viewModel,
72
+ String? token,
73
+ ) {
74
+ return [
75
+ DashboardWidgetDefinition(
76
+ id: 'cash_balance',
77
+ title: 'Cash Balance',
78
+ builder: (context) => _buildCashBalanceCard(context, viewModel),
79
+ ),
80
+ DashboardWidgetDefinition(
81
+ id: 'rbc_balance',
82
+ title: 'RBC Balance',
83
+ builder: (context) => _buildCreditCardBalanceCard(
84
+ context,
85
+ viewModel,
86
+ 'RBC Balance',
87
+ viewModel.rbcCardController,
88
+ viewModel.rbcUnpaidExpenses,
89
+ ),
90
+ ),
91
+ DashboardWidgetDefinition(
92
+ id: 'bmo_balance',
93
+ title: 'BMO Balance',
94
+ builder: (context) => _buildCreditCardBalanceCard(
95
+ context,
96
+ viewModel,
97
+ 'BMO Balance',
98
+ viewModel.bmoCardController,
99
+ viewModel.bmoUnpaidExpenses,
100
+ ),
101
+ ),
102
+ DashboardWidgetDefinition(
103
+ id: 'cash_tx',
104
+ title: 'Cash Transactions',
105
+ builder: (context) => _buildRecentTransactionsList(
106
+ 'Cash Transactions',
107
+ viewModel.cashTransactions,
108
+ ),
109
+ ),
110
+ DashboardWidgetDefinition(
111
+ id: 'rbc_tx',
112
+ title: 'RBC Transactions',
113
+ builder: (context) => _buildRecentTransactionsList(
114
+ 'RBC Transactions',
115
+ viewModel.rbcTransactions,
116
+ ),
117
+ ),
118
+ DashboardWidgetDefinition(
119
+ id: 'bmo_tx',
120
+ title: 'BMO Transactions',
121
+ builder: (context) => _buildRecentTransactionsList(
122
+ 'BMO Transactions',
123
+ viewModel.bmoTransactions,
124
+ ),
125
+ ),
126
+ DashboardWidgetDefinition(
127
+ id: 'expense_form',
128
+ title: 'New Expense',
129
+ builder: (context) => ExpenseForm(
130
+ formKey: viewModel.expenseFormKey,
131
+ onTransactionSuccess: viewModel.fetchData,
132
+ token: token,
133
+ ),
134
+ ),
135
+ DashboardWidgetDefinition(
136
+ id: 'investment_form',
137
+ title: 'Move Money / Investment',
138
+ builder: (context) => InvestmentForm(
139
+ formKey: viewModel.investmentFormKey,
140
+ accountBalances: viewModel.accountBalances,
141
+ cashBalance: viewModel.appCalculatedCash,
142
+ onTransactionSuccess: viewModel.fetchData,
143
+ token: token,
144
+ ),
145
+ ),
146
+ DashboardWidgetDefinition(
147
+ id: 'salary_form',
148
+ title: 'New Salary',
149
+ builder: (context) => SalaryForm(
150
+ formKey: viewModel.salaryFormKey,
151
+ onTransactionSuccess: viewModel.fetchData,
152
+ token: token,
153
+ ),
154
+ ),
155
+ ];
156
+ }
157
+
158
+ List<DashboardWidgetDefinition> _applyOrder(
159
+ List<DashboardWidgetDefinition> defaults,
160
+ List<String>? order,
161
+ ) {
162
+ if (order == null || order.isEmpty) return defaults;
163
+
164
+ final byId = {for (final w in defaults) w.id: w};
165
+ final ordered = <DashboardWidgetDefinition>[];
166
+
167
+ // Keep only known ids
168
+ for (final id in order) {
169
+ final w = byId[id];
170
+ if (w != null) ordered.add(w);
171
+ }
172
+
173
+ // Append new widgets that weren't in the stored order
174
+ for (final w in defaults) {
175
+ if (!ordered.any((x) => x.id == w.id)) {
176
+ ordered.add(w);
132
177
  }
178
+ }
179
+
180
+ return ordered;
181
+ }
182
+
183
+ Widget _buildReorderableMobileDashboard(
184
+ BuildContext context,
185
+ DashboardViewModel viewModel,
186
+ String? token,
187
+ ) {
188
+ if (!_mobileOrderLoaded) {
189
+ return const Center(child: CircularProgressIndicator());
190
+ }
133
191
 
134
- final pmSummary = otherResults[0] as List<Map<String, dynamic>>;
135
- _rbcUnpaidExpenses = 0.0;
136
- _bmoUnpaidExpenses = 0.0;
137
- for (var item in pmSummary) {
138
- final paymentMethod =
139
- (item['payment_method'] as String?)?.toUpperCase();
140
- if (paymentMethod == 'RBC') {
141
- _rbcUnpaidExpenses += double.tryParse(item['unpaid_amount']?.toString() ?? '0.0') ?? 0.0;
142
- } else if (paymentMethod == 'BMO') {
143
- _bmoUnpaidExpenses += double.tryParse(item['unpaid_amount']?.toString() ?? '0.0') ?? 0.0;
144
- } }
145
-
146
- _cashTransactions = otherResults[1] as List<Map<String, dynamic>>;
147
- _rbcTransactions = otherResults[2] as List<Map<String, dynamic>>;
148
- _bmoTransactions = otherResults[3] as List<Map<String, dynamic>>;
149
- _investments = otherResults[4] as List<Map<String, dynamic>>;
150
- } catch (e) {
151
- print('Error fetching data: $e');
152
- } finally {
153
- if (mounted) {
192
+ final defaults = _defaultMobileWidgets(context, viewModel, token);
193
+ final widgets = _applyOrder(defaults, _mobileOrder);
194
+
195
+ // ReorderableListView must be scrollable itself.
196
+ // We are already inside a SingleChildScrollView in the Scaffold body.
197
+ // So we disable its scroll and let the parent handle scroll.
198
+ return ReorderableListView(
199
+ shrinkWrap: true,
200
+ physics: const NeverScrollableScrollPhysics(),
201
+ onReorder: (oldIndex, newIndex) async {
202
+ final list = List<DashboardWidgetDefinition>.from(widgets);
203
+ if (newIndex > oldIndex) newIndex -= 1;
204
+ final item = list.removeAt(oldIndex);
205
+ list.insert(newIndex, item);
206
+
207
+ final ids = list.map((w) => w.id).toList();
154
208
  setState(() {
155
- _isLoading = false;
209
+ _mobileOrder = ids;
156
210
  });
157
- }
158
- }
211
+
212
+ await _layoutStorage.saveOrder(ids);
213
+ },
214
+ children: [
215
+ for (final w in widgets)
216
+ Padding(
217
+ key: ValueKey(w.id),
218
+ padding: const EdgeInsets.only(bottom: 16.0),
219
+ child: w.builder(context),
220
+ ),
221
+ ],
222
+ );
159
223
  }
160
224
 
161
- Widget _buildMainMenu(BuildContext context) {
225
+ @override
226
+ Widget build(BuildContext context) {
227
+ final authProvider = Provider.of<AuthProvider>(context, listen: false);
228
+ final token = authProvider.token;
229
+
230
+ return ChangeNotifierProvider(
231
+ create: (_) => DashboardViewModel(token),
232
+ child: Consumer<DashboardViewModel>(
233
+ builder: (context, viewModel, child) {
234
+ return Scaffold(
235
+ appBar: AppBar(
236
+ title: const Text('Personal Finance Dashboard'),
237
+ actions: [
238
+ PopupMenuButton<String>(
239
+ onSelected: (value) {
240
+ if (value == 'profile') {
241
+ Navigator.push(
242
+ context,
243
+ MaterialPageRoute(
244
+ builder: (context) => const ProfileScreen()),
245
+ );
246
+ } else if (value == 'theme') {
247
+ final themeNotifier =
248
+ Provider.of<ThemeNotifier>(context, listen: false);
249
+ final newTheme =
250
+ themeNotifier.themeMode == ThemeMode.dark
251
+ ? ThemeMode.light
252
+ : ThemeMode.dark;
253
+ themeNotifier.setThemeMode(newTheme);
254
+ } else if (value == 'logout') {
255
+ Provider.of<AuthProvider>(context, listen: false)
256
+ .logout();
257
+ Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
258
+ }
259
+ },
260
+ itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
261
+ const PopupMenuItem<String>(
262
+ value: 'profile',
263
+ child: Text('My Profile'),
264
+ ),
265
+ const PopupMenuItem<String>(
266
+ value: 'theme',
267
+ child: Text('Toggle Theme'),
268
+ ),
269
+ const PopupMenuItem<String>(
270
+ value: 'logout',
271
+ child: Text('Logout'),
272
+ ),
273
+ ],
274
+ child: const UserProfileAvatar(),
275
+ ),
276
+ const SizedBox(width: 10),
277
+ ],
278
+ ),
279
+ drawer: Drawer(
280
+ child: viewModel.selectedMenu == null
281
+ ? _buildMainMenu(context, viewModel)
282
+ : _buildSubMenu(context, viewModel),
283
+ ),
284
+ body: viewModel.isLoading
285
+ ? const Center(child: CircularProgressIndicator())
286
+ : SingleChildScrollView(
287
+ padding: const EdgeInsets.all(16.0),
288
+ child: ResponsiveBuilder(
289
+ // Mobile: stack sections vertically (single column)
290
+ mobile: (context) => _buildReorderableMobileDashboard(
291
+ context,
292
+ viewModel,
293
+ token,
294
+ ),
295
+
296
+ // Desktop (and tablet fallback): keep the wide dashboard layout.
297
+ desktop: (context) => Column(
298
+ crossAxisAlignment: CrossAxisAlignment.start,
299
+ children: [
300
+ Row(
301
+ crossAxisAlignment: CrossAxisAlignment.start,
302
+ children: [
303
+ Expanded(
304
+ child: _buildCashBalanceCard(context, viewModel),
305
+ ),
306
+ const SizedBox(width: 16),
307
+ Expanded(
308
+ child: _buildCreditCardBalanceCard(
309
+ context,
310
+ viewModel,
311
+ 'RBC Balance',
312
+ viewModel.rbcCardController,
313
+ viewModel.rbcUnpaidExpenses,
314
+ ),
315
+ ),
316
+ const SizedBox(width: 16),
317
+ Expanded(
318
+ child: _buildCreditCardBalanceCard(
319
+ context,
320
+ viewModel,
321
+ 'BMO Balance',
322
+ viewModel.bmoCardController,
323
+ viewModel.bmoUnpaidExpenses,
324
+ ),
325
+ ),
326
+ ],
327
+ ),
328
+ const SizedBox(height: 16),
329
+ Row(
330
+ crossAxisAlignment: CrossAxisAlignment.start,
331
+ children: [
332
+ Expanded(
333
+ child: _buildRecentTransactionsList(
334
+ 'Cash Transactions',
335
+ viewModel.cashTransactions,
336
+ ),
337
+ ),
338
+ const SizedBox(width: 16),
339
+ Expanded(
340
+ child: _buildRecentTransactionsList(
341
+ 'RBC Transactions',
342
+ viewModel.rbcTransactions,
343
+ ),
344
+ ),
345
+ const SizedBox(width: 16),
346
+ Expanded(
347
+ child: _buildRecentTransactionsList(
348
+ 'BMO Transactions',
349
+ viewModel.bmoTransactions,
350
+ ),
351
+ ),
352
+ const SizedBox(width: 16),
353
+ Expanded(
354
+ child: ExpenseForm(
355
+ formKey: viewModel.expenseFormKey,
356
+ onTransactionSuccess: viewModel.fetchData,
357
+ token: token,
358
+ ),
359
+ ),
360
+ ],
361
+ ),
362
+ const SizedBox(height: 16),
363
+ Row(
364
+ crossAxisAlignment: CrossAxisAlignment.start,
365
+ children: [
366
+ Expanded(
367
+ child: InvestmentForm(
368
+ formKey: viewModel.investmentFormKey,
369
+ accountBalances: viewModel.accountBalances,
370
+ cashBalance: viewModel.appCalculatedCash,
371
+ onTransactionSuccess: viewModel.fetchData,
372
+ token: token,
373
+ ),
374
+ ),
375
+ const SizedBox(width: 16),
376
+ Expanded(
377
+ child: SalaryForm(
378
+ formKey: viewModel.salaryFormKey,
379
+ onTransactionSuccess: viewModel.fetchData,
380
+ token: token,
381
+ ),
382
+ ),
383
+ ],
384
+ ),
385
+ ],
386
+ ),
387
+ ),
388
+ ),
389
+ floatingActionButton: FloatingActionButton(
390
+ onPressed: viewModel.fetchData,
391
+ tooltip: 'Refresh Data',
392
+ child: const Icon(Icons.refresh),
393
+ ),
394
+ );
395
+ },
396
+ ),
397
+ );
398
+ }
399
+
400
+ Widget _buildMainMenu(BuildContext context, DashboardViewModel viewModel) {
162
401
  return ListView(
163
402
  padding: EdgeInsets.zero,
164
403
  children: <Widget>[
165
404
  const DrawerHeader(
166
405
  decoration: BoxDecoration(
406
+ // TODO(UI): replace hardcoded color with a theme token (e.g. theme.colorScheme.primary)
407
+ // or a semantic token in AppColors if needed.
167
408
  color: Colors.blue,
168
409
  ),
169
410
  child: Text(
@@ -177,53 +418,37 @@ class _HomeScreenState extends State<HomeScreen> {
177
418
  ListTile(
178
419
  leading: const Icon(Icons.assessment),
179
420
  title: const Text('Investments'),
180
- onTap: () {
181
- setState(() {
182
- _selectedMenu = 'investments';
183
- });
184
- },
421
+ onTap: () => viewModel.setMenu('investments'),
185
422
  ),
186
423
  ListTile(
187
424
  leading: const Icon(Icons.description),
188
425
  title: const Text('Reports'),
189
- onTap: () {
190
- setState(() {
191
- _selectedMenu = 'reports';
192
- });
193
- },
426
+ onTap: () => viewModel.setMenu('reports'),
194
427
  ),
195
428
  ListTile(
196
429
  leading: const Icon(Icons.bar_chart),
197
430
  title: const Text('Charts'),
198
- onTap: () {
199
- setState(() {
200
- _selectedMenu = 'charts';
201
- });
202
- },
431
+ onTap: () => viewModel.setMenu('charts'),
203
432
  ),
204
433
  ListTile(
205
434
  leading: const Icon(Icons.settings),
206
435
  title: const Text('Management'),
207
- onTap: () {
208
- setState(() {
209
- _selectedMenu = 'management';
210
- });
211
- },
436
+ onTap: () => viewModel.setMenu('management'),
212
437
  ),
213
438
  ],
214
439
  );
215
440
  }
216
441
 
217
- Widget _buildSubMenu(BuildContext context) {
442
+ Widget _buildSubMenu(BuildContext context, DashboardViewModel viewModel) {
218
443
  List<Widget> items;
219
444
  String title;
220
445
 
221
- switch (_selectedMenu) {
446
+ switch (viewModel.selectedMenu) {
222
447
  case 'investments':
223
448
  title = 'Investments';
224
449
  items = [
225
- ..._investmentAccountNames.map((accountName) {
226
- final balance = _accountBalances[accountName] ?? 0.0;
450
+ ...viewModel.investmentAccountNames.map((accountName) {
451
+ final balance = viewModel.accountBalances[accountName] ?? 0.0;
227
452
  final formattedBalance = _formatCurrency(balance);
228
453
  return ListTile(
229
454
  title: Text(' $accountName - $formattedBalance'),
@@ -350,7 +575,7 @@ class _HomeScreenState extends State<HomeScreen> {
350
575
  context,
351
576
  MaterialPageRoute(
352
577
  builder: (context) => const ManageTransactionsScreen()),
353
- ).then((_) => _fetchData());
578
+ ).then((_) => viewModel.fetchData());
354
579
  },
355
580
  ),
356
581
  ListTile(
@@ -380,11 +605,7 @@ class _HomeScreenState extends State<HomeScreen> {
380
605
  backgroundColor: Theme.of(context).primaryColor,
381
606
  leading: IconButton(
382
607
  icon: const Icon(Icons.arrow_back),
383
- onPressed: () {
384
- setState(() {
385
- _selectedMenu = null;
386
- });
387
- },
608
+ onPressed: () => viewModel.setMenu(null),
388
609
  ),
389
610
  automaticallyImplyLeading: false,
390
611
  ),
@@ -393,129 +614,10 @@ class _HomeScreenState extends State<HomeScreen> {
393
614
  );
394
615
  }
395
616
 
396
- @override
397
- Widget build(BuildContext context) {
398
- final double totalUserCash =
399
- CurrencyInputFormatter.unformat(_rbcCashController.text) +
400
- CurrencyInputFormatter.unformat(_wealthsimpleCashController.text);
401
-
402
- final double appCalculatedCash = _accountBalances['CASH'] ?? 0.0;
403
- final token = Provider.of<AuthProvider>(context, listen: false).token;
404
-
405
- return Scaffold(
406
- appBar: AppBar(
407
- title: const Text('Personal Finance Dashboard'),
408
- actions: [
409
- const UserProfileAvatar(),
410
- Consumer<ThemeNotifier>(
411
- builder: (context, themeNotifier, child) {
412
- return IconButton(
413
- icon: Icon(themeNotifier.themeMode == ThemeMode.dark
414
- ? Icons.dark_mode
415
- : Icons.light_mode),
416
- onPressed: () {
417
- themeNotifier.setThemeMode(
418
- themeNotifier.themeMode == ThemeMode.dark
419
- ? ThemeMode.light
420
- : ThemeMode.dark);
421
- },
422
- );
423
- },
424
- ),
425
- IconButton(
426
- icon: const Icon(Icons.logout),
427
- onPressed: () {
428
- Provider.of<AuthProvider>(context, listen: false).logout();
429
- },
430
- ),
431
- ],
432
- ),
433
- drawer: Drawer(
434
- child: _selectedMenu == null
435
- ? _buildMainMenu(context)
436
- : _buildSubMenu(context),
437
- ),
438
- body: _isLoading
439
- ? const Center(child: CircularProgressIndicator())
440
- : SingleChildScrollView(
441
- padding: const EdgeInsets.all(16.0),
442
- child: Column(
443
- crossAxisAlignment: CrossAxisAlignment.start,
444
- children: [
445
- Row(
446
- crossAxisAlignment: CrossAxisAlignment.start,
447
- children: [
448
- Expanded(
449
- child: _buildCashBalanceCard(
450
- totalUserCash, appCalculatedCash)),
451
- const SizedBox(width: 16),
452
- Expanded(
453
- child: _buildCreditCardBalanceCard('RBC Balance',
454
- _rbcCardController, _rbcUnpaidExpenses)),
455
- const SizedBox(width: 16),
456
- Expanded(
457
- child: _buildCreditCardBalanceCard('BMO Balance',
458
- _bmoCardController, _bmoUnpaidExpenses)),
459
- ],
460
- ),
461
- const SizedBox(height: 16),
462
- Row(
463
- crossAxisAlignment: CrossAxisAlignment.start,
464
- children: [
465
- Expanded(
466
- child: _buildRecentTransactionsList(
467
- 'Cash Transactions', _cashTransactions)),
468
- const SizedBox(width: 16),
469
- Expanded(
470
- child: _buildRecentTransactionsList(
471
- 'RBC Transactions', _rbcTransactions)),
472
- const SizedBox(width: 16),
473
- Expanded(
474
- child: _buildRecentTransactionsList(
475
- 'BMO Transactions', _bmoTransactions)),
476
- const SizedBox(width: 16),
477
- Expanded(
478
- child: ExpenseForm(
479
- formKey: _expenseFormKey,
480
- onTransactionSuccess: _fetchData,
481
- token: token,
482
- )),
483
- ],
484
- ),
485
- const SizedBox(height: 16),
486
- Row(
487
- crossAxisAlignment: CrossAxisAlignment.start,
488
- children: [
489
- Expanded(
490
- child: InvestmentForm(
491
- formKey: _investmentFormKey,
492
- accountBalances: _accountBalances,
493
- cashBalance: appCalculatedCash,
494
- onTransactionSuccess: _fetchData,
495
- token: token,
496
- )),
497
- const SizedBox(width: 16),
498
- Expanded(
499
- child: SalaryForm(
500
- formKey: _salaryFormKey,
501
- onTransactionSuccess: _fetchData,
502
- token: token,
503
- )),
504
- ],
505
- ),
506
- ],
507
- ),
508
- ),
509
- floatingActionButton: FloatingActionButton(
510
- onPressed: _fetchData,
511
- tooltip: 'Refresh Data',
512
- child: const Icon(Icons.refresh),
513
- ),
514
- );
515
- }
516
-
517
- Widget _buildCashBalanceCard(double totalUserCash, double appCalculatedCash) {
518
- final double difference = totalUserCash - appCalculatedCash;
617
+ Widget _buildCashBalanceCard(
618
+ BuildContext context, DashboardViewModel viewModel) {
619
+ final double difference =
620
+ viewModel.totalUserCash - viewModel.appCalculatedCash;
519
621
 
520
622
  String differenceLabel = 'Balance is correct';
521
623
  Color differenceColor = Theme.of(context).colorScheme.secondary;
@@ -523,11 +625,12 @@ class _HomeScreenState extends State<HomeScreen> {
523
625
 
524
626
  if (difference > 0.01) {
525
627
  differenceLabel = 'Missing Income:';
526
- differenceColor = Colors.green;
628
+ // Use semantic color token instead of hardcoded Colors.green
629
+ differenceColor = AppColors.success;
527
630
  differenceText = '+${_formatCurrency(difference)}';
528
631
  } else if (difference < -0.01) {
529
632
  differenceLabel = 'Missing Expenses:';
530
- differenceColor = Colors.red;
633
+ differenceColor = AppColors.error;
531
634
  }
532
635
 
533
636
  return AppFormCard(
@@ -535,17 +638,17 @@ class _HomeScreenState extends State<HomeScreen> {
535
638
  child: Column(
536
639
  crossAxisAlignment: CrossAxisAlignment.start,
537
640
  children: [
538
- _buildBalanceTextField(_rbcCashController, 'RBC Bank Balance'),
641
+ _buildBalanceTextField(viewModel.rbcCashController, 'RBC Bank Balance'),
539
642
  const SizedBox(height: 12),
540
643
  _buildBalanceTextField(
541
- _wealthsimpleCashController, 'Wealthsimple Balance'),
644
+ viewModel.wealthsimpleCashController, 'Wealthsimple Balance'),
542
645
  const SizedBox(height: 16),
543
646
  const Divider(),
544
647
  const SizedBox(height: 10),
545
- _buildResultRow(
546
- 'Manual Entry Balance:', '${_formatCurrency(totalUserCash)}'),
547
- _buildResultRow(
548
- 'Calculated Balance:', '${_formatCurrency(appCalculatedCash)}'),
648
+ _buildResultRow('Manual Entry Balance:',
649
+ '${_formatCurrency(viewModel.totalUserCash)}'),
650
+ _buildResultRow('Calculated Balance:',
651
+ '${_formatCurrency(viewModel.appCalculatedCash)}'),
549
652
  const SizedBox(height: 10),
550
653
  _buildResultRow(differenceLabel, differenceText,
551
654
  isHighlighted: true, color: differenceColor),
@@ -555,7 +658,11 @@ class _HomeScreenState extends State<HomeScreen> {
555
658
  }
556
659
 
557
660
  Widget _buildCreditCardBalanceCard(
558
- String title, TextEditingController controller, double appExpenses) {
661
+ BuildContext context,
662
+ DashboardViewModel viewModel,
663
+ String title,
664
+ TextEditingController controller,
665
+ double appExpenses) {
559
666
  final double userBalance = CurrencyInputFormatter.unformat(controller.text);
560
667
  final double difference = appExpenses - userBalance;
561
668
 
@@ -565,11 +672,11 @@ class _HomeScreenState extends State<HomeScreen> {
565
672
 
566
673
  if (difference > 0.01) {
567
674
  differenceLabel = 'Missing Refund:';
568
- differenceColor = Colors.green;
675
+ differenceColor = AppColors.success;
569
676
  differenceText = '+${_formatCurrency(difference)}';
570
677
  } else if (difference < -0.01) {
571
678
  differenceLabel = 'Missing Expenses:';
572
- differenceColor = Colors.red;
679
+ differenceColor = AppColors.error;
573
680
  }
574
681
 
575
682
  return AppFormCard(
@@ -605,7 +712,8 @@ class _HomeScreenState extends State<HomeScreen> {
605
712
  builder: (context) =>
606
713
  ReconciliationScreen(paymentMethod: paymentMethod),
607
714
  ),
608
- ).then((_) => _fetchData()); // Refresh data when returning
715
+ ).then((_) =>
716
+ viewModel.fetchData()); // Refresh data when returning
609
717
  },
610
718
  child: const Text('Reconcile'),
611
719
  ),
@@ -627,7 +735,6 @@ class _HomeScreenState extends State<HomeScreen> {
627
735
  ),
628
736
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
629
737
  inputFormatters: [CurrencyInputFormatter()],
630
- onChanged: (value) => setState(() {}),
631
738
  );
632
739
  }
633
740
 
@@ -659,13 +766,14 @@ class _HomeScreenState extends State<HomeScreen> {
659
766
  children: transactions.map((transaction) {
660
767
  final date = DateFormat('MM/dd')
661
768
  .format(DateTime.parse(transaction['date']));
662
- final amount = (double.tryParse(transaction['amount']?.toString() ?? '0.0') ?? 0.0)
663
- .toStringAsFixed(2);
769
+ final amount =
770
+ (double.tryParse(transaction['amount']?.toString() ?? '0.0') ?? 0.0)
771
+ .toStringAsFixed(2);
664
772
  final subcategory =
665
773
  transaction['subcategory'] ?? 'No subcategory';
666
774
  final type = transaction['type'] == 'debit' ? '-' : '+';
667
775
  final color =
668
- transaction['type'] == 'debit' ? Colors.red : Colors.green;
776
+ transaction['type'] == 'debit' ? AppColors.error : AppColors.success;
669
777
 
670
778
  return Padding(
671
779
  padding: const EdgeInsets.symmetric(vertical: 4.0),
@@ -0,0 +1,21 @@
1
+ import 'package:shared_preferences/shared_preferences.dart';
2
+
3
+ /// Local persistence for dashboard widget order.
4
+ ///
5
+ /// MVP: store a list of widget IDs in SharedPreferences.
6
+ /// If nothing is stored yet, we fall back to the default order.
7
+ class DashboardLayoutStorage {
8
+ static const _key = 'dashboard_widget_order_v1';
9
+
10
+ Future<List<String>?> loadOrder() async {
11
+ final prefs = await SharedPreferences.getInstance();
12
+ final list = prefs.getStringList(_key);
13
+ if (list == null || list.isEmpty) return null;
14
+ return list;
15
+ }
16
+
17
+ Future<void> saveOrder(List<String> ids) async {
18
+ final prefs = await SharedPreferences.getInstance();
19
+ await prefs.setStringList(_key, ids);
20
+ }
21
+ }
@@ -12,6 +12,11 @@ class DashboardViewModel extends ChangeNotifier {
12
12
  final rbcCardController = TextEditingController();
13
13
  final bmoCardController = TextEditingController();
14
14
 
15
+ // Form Keys
16
+ final expenseFormKey = GlobalKey<FormState>();
17
+ final investmentFormKey = GlobalKey<FormState>();
18
+ final salaryFormKey = GlobalKey<FormState>();
19
+
15
20
  // State variables
16
21
  bool _isLoading = true;
17
22
  String? _selectedMenu;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcos_feitoza/personal-finance-frontend-feature-dashboard",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/pubspec.yaml CHANGED
@@ -11,6 +11,7 @@ dependencies:
11
11
  sdk: flutter
12
12
  intl: ^0.17.0
13
13
  provider: ^6.0.0
14
+ shared_preferences: ^2.0.0
14
15
  personal_finance_frontend_core_services:
15
16
  path: ../personal-finance-frontend-core-services
16
17
  personal_finance_frontend_core_ui:
@@ -23,6 +24,8 @@ dependencies:
23
24
  path: ../personal-finance-frontend-feature-investments
24
25
  personal_finance_frontend_feature_management:
25
26
  path: ../personal-finance-frontend-feature-management
27
+ personal_finance_frontend_feature_profile:
28
+ path: ../personal-finance-frontend-feature-profile
26
29
 
27
30
  dev_dependencies:
28
31
  flutter_test: