dev
dart 511 lines 20 KB
Raw
1 import 'dart:ui';
2 import 'package:cake_wallet/di.dart';
3 import 'package:cake_wallet/generated/i18n.dart';
4 import 'package:cake_wallet/monero/monero.dart';
5 import 'package:cake_wallet/new-ui/pages/card_customizer.dart';
6 import 'package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart';
7 import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart';
8 import 'package:cake_wallet/new-ui/widgets/modal_grab_handle.dart';
9 import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart';
10 import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
11 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
12 import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
13 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
14 import 'package:cake_wallet/utils/show_pop_up.dart';
15 import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
16 import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart';
17 import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart';
18 import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart';
19 import 'package:cw_core/balance_card_style_settings.dart';
20 import 'package:cw_core/card_design.dart';
21 import 'package:cw_core/generate_name.dart';
22 import 'package:cw_core/sync_status.dart';
23 import 'package:cw_core/utils/print_verbose.dart';
24 import 'package:flutter/cupertino.dart';
25 import 'package:flutter/material.dart';
26 import 'package:flutter_bloc/flutter_bloc.dart';
27 import 'package:flutter_svg/flutter_svg.dart';
28 import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
29
30 class AccountCustomizerListItem {
31 final BalanceCard card;
32 final int order;
33 final AccountListItem accountListItem;
34
35 AccountCustomizerListItem(
36 {required this.card, required this.order, required this.accountListItem});
37 }
38
39 class AccountCustomizer extends StatefulWidget {
40 const AccountCustomizer(
41 {super.key,
42 required this.accountListViewModel,
43 required this.accountEditOrCreateViewModel,
44 required this.dashboardViewModel});
45
46 final MoneroAccountListViewModel accountListViewModel;
47 final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel;
48 final DashboardViewModel dashboardViewModel;
49
50 @override
51 State<AccountCustomizer> createState() => _AccountCustomizerState();
52 }
53
54 class _AccountCustomizerState extends State<AccountCustomizer> {
55 static const double _kStackVisibleFactor = 0.2;
56 late final double cardWidth = MediaQuery.of(context).size.width * 0.9;
57
58 final List<AccountCustomizerListItem> _items = [];
59
60 @override
61 void initState() {
62 super.initState();
63 WidgetsBinding.instance.addPostFrameCallback((_) {
64 loadCards();
65 final activeId = monero!.getCurrentAccount(widget.dashboardViewModel.wallet).id;
66 for (int i = 0; i < _items.length - 1; i++) {
67 if (_items[i].accountListItem.id == activeId) {
68 final lastIndex = _items.length - 1;
69 final temp = _items[i];
70 _items[i] = _items[lastIndex];
71 _items[lastIndex] = temp;
72 saveCardOrder();
73 widget.dashboardViewModel.loadCardDesigns();
74 break;
75 }
76 }
77 });
78 }
79
80 @override
81 void dispose() {
82 saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns());
83 super.dispose();
84 }
85
86 void loadCards() {
87 _items.clear();
88
89 final accounts = widget.accountListViewModel.accounts;
90 for (int i = 0; i < accounts.length; i++) {
91 final index = widget.dashboardViewModel.cardOrder[i];
92
93 if (index == null || index >= accounts.length) {
94 // db order broken.
95 reset();
96 break;
97 }
98
99 _items.add(AccountCustomizerListItem(
100 card: BalanceCard(
101 accountName: accounts[index].label,
102 accountIndex: accounts[index].id,
103 balance: accounts[index].balance ?? "0.00",
104 accountBalance: accounts[index].balance ?? "0.00",
105 designSwitchDuration: Duration.zero,
106 assetName: widget.accountListViewModel.currency.title,
107 onCustomizeTapped: (i == accounts.length - 1) ? _openCardCustomizer : null,
108 selected: i == accounts.length - 1,
109 width: cardWidth,
110 design: widget.dashboardViewModel.cardDesigns[index],
111 ),
112 order: index,
113 accountListItem: accounts[index]));
114 }
115 setState(() {});
116 }
117
118 @override
119 Widget build(BuildContext context) {
120 if (_items.isEmpty) return SizedBox.shrink();
121
122 return Container(
123 decoration: BoxDecoration(
124 color: Theme.of(context).colorScheme.surface,
125 borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
126 child: Column(
127 children: [
128 ModalTopBar(
129 title: S.of(context).wallet_accounts,
130 leadingIcon: Icon(Icons.close),
131 leadingSemanticLabel: S.of(context).close,
132 onLeadingPressed: Navigator.of(context).maybePop,
133 trailingIcon: Icon(Icons.refresh),
134 trailingSemanticLabel: S.of(context).reset,
135 onTrailingPressed: showResetDialog,
136 ),
137 Padding(
138 padding: const EdgeInsets.symmetric(vertical: 24.0),
139 child: Text(
140 S.of(context).account_customizer_desc,
141 style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
142 ),
143 ),
144 Expanded(
145 child: Stack(
146 children: [
147 ReorderableListView.builder(
148 padding: EdgeInsets.only(bottom: 196),
149 scrollController: ModalScrollController.of(context),
150 onReorder: reorder,
151 proxyDecorator: (child, index, animation) {
152 return AnimatedBuilder(
153 animation: animation,
154 builder: (context, _) {
155 final animValue = Curves.easeOutCubic.transform(animation.value);
156 final scale = lerpDouble(1, 1.05, animValue)!;
157
158 return Opacity(
159 opacity: 1 - animValue.clamp(0.0, 0.1),
160 child: Center(
161 child: SizedBox(
162 width: cardWidth,
163 child: Transform.scale(
164 scale: scale,
165 child: child,
166 ),
167 ),
168 ),
169 );
170 },
171 child: _items[index].card,
172 );
173 },
174 itemCount: _items.length,
175 itemBuilder: (BuildContext context, int index) {
176 final card = _items[index].card;
177 // The stack is ordered bottom to top, so the last item
178 // is the account currently in front — the selected one.
179 final selectedItemIndex = _items.length - 1;
180
181 return Container(
182 key: ValueKey(index),
183 // One labeled, selectable node per account; the card's own
184 // texts stay reachable underneath it.
185 child: Semantics(
186 button: true,
187 selected: selectedItemIndex == index,
188 label: _items[index].accountListItem.label,
189 onTap: () => reorder(index, _items.length),
190 child: GestureDetector(
191 excludeFromSemantics: true,
192 onTap: () {
193 reorder(index, _items.length);
194 },
195 child: Align(
196 alignment: Alignment.topCenter,
197 heightFactor: _kStackVisibleFactor,
198 child: card,
199 ),
200 ),
201 ),
202 );
203 },
204 ),
205 SafeArea(
206 child: Padding(
207 padding: EdgeInsets.only(bottom: 50),
208 child: Align(
209 alignment: Alignment.bottomCenter,
210 child: Padding(
211 padding: const EdgeInsets.symmetric(horizontal: 24.0),
212 child: Material(
213 color: Colors.transparent,
214 child: MergeSemantics(
215 child: Semantics(
216 button: true,
217 child: InkWell(
218 borderRadius: BorderRadius.circular(999999),
219 onTap: _showAddAccountModal,
220 child: Container(
221 decoration: BoxDecoration(
222 color: Theme.of(context).colorScheme.surfaceContainer,
223 borderRadius: BorderRadius.circular(999999)),
224 child: Padding(
225 padding: const EdgeInsets.symmetric(vertical: 18.0),
226 child: Row(
227 mainAxisAlignment: MainAxisAlignment.center,
228 spacing: 8,
229 children: [
230 Icon(
231 Icons.add,
232 size: 28,
233 color: Theme.of(context).colorScheme.primary,
234 ),
235 Text(
236 S.of(context).add_account,
237 style: TextStyle(
238 color: Theme.of(context).colorScheme.primary,
239 fontWeight: FontWeight.w500),
240 )
241 ],
242 ),
243 ),
244 ),
245 ),
246 ),
247 ),
248 ),
249 )))),
250 ],
251 ),
252 ),
253 ],
254 ),
255 );
256 }
257
258 bool _checkReadyToManage() {
259 if (widget.dashboardViewModel.status is! SyncedSyncStatus) {
260 showDialog(
261 context: context,
262 builder: (context) => AlertWithOneAction(
263 alertTitle: S.of(context).wallet_is_syncing,
264 alertContent: S.of(context).cannot_manage_accounts_during_sync,
265 buttonText: S.of(context).ok,
266 buttonAction: Navigator.of(context).pop));
267 return false;
268 }
269 return true;
270 }
271
272 Future<void> _showAddAccountModal() async {
273 if (!_checkReadyToManage()) {
274 return;
275 }
276
277 final res = await showCupertinoModalBottomSheet(
278 context: context,
279 backgroundColor: Colors.transparent,
280 builder: (context) {
281 final modal = getIt.get<AccountCreationModal>();
282 return Material(child: modal);
283 });
284 if (res != null && res is bool && res == true) {
285 await widget.dashboardViewModel.loadCardDesigns();
286 loadCards();
287 await saveCardOrder();
288 }
289 }
290
291 void _openCardCustomizer() {
292 if (!_checkReadyToManage()) {
293 return;
294 }
295
296 final bloc = getIt.get<CardCustomizerBloc>(param1: false);
297
298 Navigator.of(context).push(CupertinoPageRoute(
299 builder: (context) {
300 return BlocProvider(
301 create: (context) => bloc,
302 child: Material(
303 child: CardCustomizer(
304 cryptoTitle: widget.dashboardViewModel.wallet.currency.fullName ??
305 widget.dashboardViewModel.wallet.currency.name,
306 cryptoName: widget.dashboardViewModel.wallet.currency.name,
307 ),
308 ),
309 );
310 },
311 )).then((_) async {
312 bloc.add(DesignSaved());
313 await bloc.stream.firstWhere((item) => item is CardCustomizerSaved);
314 await widget.dashboardViewModel.loadCardDesigns();
315 loadCards();
316 });
317 }
318
319 void reorder(int oldIndex, int newIndex) {
320 setState(() {
321 if (oldIndex < newIndex) {
322 newIndex -= 1;
323 }
324 final AccountCustomizerListItem item = _items.removeAt(oldIndex);
325 _items.insert(newIndex, item);
326 });
327
328 // necessary to copy all this to keep constant constructor for BalanceCard
329 for (int i = 0; i < _items.length; i++) {
330 _items[i] = AccountCustomizerListItem(
331 card: BalanceCard(
332 accountName: _items[i].card.accountName,
333 balance: _items[i].card.balance,
334 accountIndex: _items[i].card.accountIndex,
335 accountBalance: _items[i].card.accountBalance,
336 assetName: _items[i].card.assetName,
337 designSwitchDuration: _items[i].card.designSwitchDuration,
338 onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null,
339 selected: i == _items.length - 1,
340 width: _items[i].card.width,
341 design: _items[i].card.design,
342 ),
343 order: i,
344 accountListItem: _items[i].accountListItem);
345 }
346
347 if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) {
348 widget.accountListViewModel.select(_items[_items.length - 1].accountListItem);
349 }
350 }
351
352 Future<void> saveCardOrder() async {
353 for (int i = 0; i < _items.length; i++) {
354 final idx = _items.indexWhere((element) => element.accountListItem.id == i);
355 printV("$i: $idx");
356
357 await BalanceCardStyleSettings.fromCardDesign(
358 walletInfoId: widget.dashboardViewModel.wallet.walletInfo.internalId,
359 accountIndex: i,
360 cardOrder: idx,
361 design: _items[idx].card.design)
362 .insert();
363 }
364 }
365
366 Future<void> showResetDialog() async {
367 final res = await showPopUp(
368 context: context,
369 builder: (context) {
370 return AlertWithTwoActions(
371 alertTitle: S.of(context).reset,
372 alertContent: S.of(context).card_order_reset_desc,
373 leftButtonText: S.of(context).yes,
374 rightButtonText: S.of(context).no,
375 actionLeftButton: () {
376 Navigator.of(context).pop(true);
377 },
378 actionRightButton: Navigator.of(context).pop);
379 });
380 if (res != null && res is bool && res) {
381 reset();
382 }
383 }
384
385 Future<void> reset() async {
386 _items.clear();
387
388 final accounts = widget.accountListViewModel.accounts;
389 for (int i = 0; i < widget.accountListViewModel.accounts.length; i++) {
390 _items.add(AccountCustomizerListItem(
391 card: BalanceCard(
392 accountName: accounts[i].label,
393 accountIndex: accounts[i].id,
394 balance: accounts[i].balance ?? "0.00",
395 accountBalance: accounts[i].balance ?? "0.00",
396 assetName: widget.accountListViewModel.currency.title,
397 selected: true,
398 designSwitchDuration: Duration(milliseconds: 200),
399 width: cardWidth,
400 design: i >= widget.dashboardViewModel.cardDesigns.length
401 ? CardDesign.genericDefault
402 : widget.dashboardViewModel.cardDesigns[i],
403 ),
404 order: i,
405 accountListItem: accounts[i]));
406 }
407
408 saveCardOrder();
409 setState(() {});
410 }
411 }
412
413 class AccountCreationModal extends StatefulWidget {
414 const AccountCreationModal({super.key, required this.accountEditOrCreateViewModel});
415
416 final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel;
417
418 @override
419 State<AccountCreationModal> createState() => _AccountCreationModalState();
420 }
421
422 class _AccountCreationModalState extends State<AccountCreationModal> {
423 final TextEditingController _controller = TextEditingController();
424 bool _loading = false;
425
426 Future<void> _generateAccountName() async => _controller.text = await generateName();
427
428 @override
429 Widget build(BuildContext context) {
430 return Container(
431 decoration: BoxDecoration(
432 color: Theme.of(context).colorScheme.surface,
433 borderRadius: BorderRadius.vertical(top: Radius.circular(30))),
434 child: SafeArea(
435 top: false,
436 child: Padding(
437 padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
438 child: Column(
439 mainAxisSize: MainAxisSize.min,
440 children: [
441 ModalGrabHandle(),
442 ModalTopBar(title: S.of(context).create_account),
443 Padding(
444 padding: const EdgeInsets.symmetric(horizontal: 18.0),
445 child: Column(
446 spacing: 50,
447 children: [
448 SizedBox(),
449 Container(
450 decoration: BoxDecoration(
451 color: Theme.of(context).colorScheme.surfaceContainer,
452 borderRadius: BorderRadius.circular(16)),
453 child: Row(
454 children: [
455 Expanded(
456 child: TextField(
457 controller: _controller,
458 decoration: InputDecoration(hintText: S.of(context).account_name),
459 ),
460 ),
461 Padding(
462 padding: const EdgeInsets.all(12.0),
463 child: Semantics(
464 button: true,
465 label: S.of(context).generate_name,
466 onTap: _generateAccountName,
467 child: ExcludeSemantics(
468 child: GestureDetector(
469 onTap: _generateAccountName,
470 child: Container(
471 decoration: BoxDecoration(
472 color: Theme.of(context).colorScheme.surfaceContainerHigh,
473 borderRadius: BorderRadius.circular(5)),
474 child: CakeImageWidget(
475 imageUrl: "assets/new-ui/randomize.svg",
476 colorFilter: ColorFilter.mode(
477 Theme.of(context).colorScheme.primary, BlendMode.srcIn),
478 ),
479 ),
480 ),
481 ),
482 ),
483 )
484 ],
485 ),
486 ),
487 NewPrimaryButton(
488 onPressed: () async {
489 if (_loading) return;
490 setState(() {
491 _loading = true;
492 });
493 widget.accountEditOrCreateViewModel.label = _controller.text;
494 await widget.accountEditOrCreateViewModel.save();
495 Navigator.of(context).pop(true);
496 },
497 text: S.of(context).continue_text,
498 color: Theme.of(context).colorScheme.primary,
499 textColor: Theme.of(context).colorScheme.onPrimary,
500 isLoading: _loading,
501 ),
502 SizedBox(),
503 ],
504 ),
505 )
506 ],
507 ),
508 ),
509 ));
510 }
511 }