@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
|
-
|
|
30
|
-
|
|
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',
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
64
|
+
_mobileOrder = saved;
|
|
65
|
+
_mobileOrderLoaded = true;
|
|
102
66
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
446
|
+
switch (viewModel.selectedMenu) {
|
|
222
447
|
case 'investments':
|
|
223
448
|
title = 'Investments';
|
|
224
449
|
items = [
|
|
225
|
-
...
|
|
226
|
-
final balance =
|
|
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((_) =>
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
final double
|
|
399
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
641
|
+
_buildBalanceTextField(viewModel.rbcCashController, 'RBC Bank Balance'),
|
|
539
642
|
const SizedBox(height: 12),
|
|
540
643
|
_buildBalanceTextField(
|
|
541
|
-
|
|
644
|
+
viewModel.wealthsimpleCashController, 'Wealthsimple Balance'),
|
|
542
645
|
const SizedBox(height: 16),
|
|
543
646
|
const Divider(),
|
|
544
647
|
const SizedBox(height: 10),
|
|
545
|
-
_buildResultRow(
|
|
546
|
-
'
|
|
547
|
-
_buildResultRow(
|
|
548
|
-
'
|
|
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
|
-
|
|
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 =
|
|
675
|
+
differenceColor = AppColors.success;
|
|
569
676
|
differenceText = '+${_formatCurrency(difference)}';
|
|
570
677
|
} else if (difference < -0.01) {
|
|
571
678
|
differenceLabel = 'Missing Expenses:';
|
|
572
|
-
differenceColor =
|
|
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((_) =>
|
|
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 =
|
|
663
|
-
.
|
|
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' ?
|
|
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
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:
|