dev
dart 539 lines 19.4 KB
Raw
1 import 'package:cake_wallet/core/auth_service.dart';
2 import 'package:cake_wallet/entities/contact_base.dart';
3 import 'package:cake_wallet/entities/contact_record.dart';
4 import 'package:cake_wallet/entities/wallet_contact.dart';
5 import 'package:cake_wallet/entities/wallet_list_order_types.dart';
6 import 'package:cake_wallet/generated/i18n.dart';
7 import 'package:cake_wallet/routes.dart';
8 import 'package:cake_wallet/src/screens/base_page.dart';
9 import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart';
10 import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart';
11 import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
12 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
13 import 'package:cake_wallet/src/widgets/gradient_background.dart';
14 import 'package:cake_wallet/src/widgets/standard_list.dart';
15 import 'package:cake_wallet/utils/address_formatter.dart';
16 import 'package:cake_wallet/utils/feature_flag.dart';
17 import 'package:cake_wallet/utils/show_bar.dart';
18 import 'package:cake_wallet/utils/show_pop_up.dart';
19 import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
20 import 'package:cw_core/crypto_currency.dart';
21 import 'package:cw_core/currency_for_wallet_type.dart';
22 import 'package:cw_core/wallet_type.dart';
23 import 'package:flutter/cupertino.dart';
24 import 'package:flutter/material.dart';
25 import 'package:flutter/services.dart';
26 import 'package:flutter_mobx/flutter_mobx.dart';
27 import 'package:flutter_slidable/flutter_slidable.dart';
28
29 class ContactListPage extends BasePage {
30 ContactListPage(this.contactListViewModel, this.authService, {this.showAddButton = false});
31
32 final ContactListViewModel contactListViewModel;
33 final AuthService authService;
34 final bool showAddButton;
35
36 @override
37 bool get gradientBackground => true;
38
39 @override
40 String get title => S.current.address_book;
41
42 @override
43 Widget? trailing(BuildContext context) {
44 if (!showAddButton) return SizedBox.shrink();
45 return MergeSemantics(
46 child: Container(
47 width: 36.0,
48 height: 36.0,
49 decoration: BoxDecoration(
50 shape: BoxShape.circle,
51 color: Theme.of(context).colorScheme.surfaceContainer,
52 ),
53 child: Semantics(
54 label: S.of(context).add_contact,
55 button: true,
56 child: Stack(
57 alignment: Alignment.center,
58 children: <Widget>[
59 Icon(
60 Icons.add,
61 color: Theme.of(context).colorScheme.primary,
62 size: 22.0,
63 ),
64 ButtonTheme(
65 minWidth: 32.0,
66 height: 32.0,
67 child: TextButton(
68 // FIX-ME: Style
69 //shape: CircleBorder(),
70 onPressed: () async {
71 if (contactListViewModel.shouldRequireTOTP2FAForAddingContacts) {
72 authService.authenticateAction(
73 context,
74 route: Routes.addressBookAddContact,
75 conditionToDetermineIfToUse2FA:
76 contactListViewModel.shouldRequireTOTP2FAForAddingContacts,
77 );
78 } else {
79 await Navigator.of(context).pushNamed(Routes.addressBookAddContact);
80 }
81 },
82 child: Offstage(),
83 ),
84 )
85 ],
86 ),
87 ),
88 ),
89 );
90 }
91
92 @override
93 Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel);
94 }
95
96 class ContactPageBody extends StatefulWidget {
97 const ContactPageBody({required this.contactListViewModel});
98
99 final ContactListViewModel contactListViewModel;
100
101 @override
102 State<ContactPageBody> createState() => _ContactPageBodyState();
103 }
104
105 class _ContactPageBodyState extends State<ContactPageBody> with SingleTickerProviderStateMixin {
106 late TabController _tabController;
107 late ContactListViewModel contactListViewModel;
108
109 @override
110 void initState() {
111 super.initState();
112 _tabController = TabController(length: 2, vsync: this);
113 contactListViewModel = widget.contactListViewModel;
114 }
115
116 @override
117 void dispose() {
118 _tabController.dispose();
119 contactListViewModel.dispose();
120 super.dispose();
121 }
122
123 @override
124 Widget build(BuildContext context) {
125 return GradientBackground(
126 scaffold: Padding(
127 padding: const EdgeInsets.only(),
128 child: Column(
129 children: [
130 Padding(
131 padding: const EdgeInsets.only(left: 24, right: 24, bottom: 8),
132 child: Align(
133 alignment: Alignment.centerLeft,
134 child: TabBar(
135 controller: _tabController,
136 splashFactory: NoSplash.splashFactory,
137 indicatorSize: TabBarIndicatorSize.label,
138 isScrollable: true,
139 labelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(
140 fontSize: 20,
141 fontWeight: FontWeight.w600,
142 color: Theme.of(context).colorScheme.primary,
143 ),
144 unselectedLabelStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(
145 fontSize: 20,
146 fontWeight: FontWeight.w600,
147 color: Theme.of(context).colorScheme.onSurfaceVariant,
148 ),
149 indicatorColor: Theme.of(context).colorScheme.primary,
150 indicatorPadding: EdgeInsets.zero,
151 labelPadding: EdgeInsets.only(right: 24),
152 tabAlignment: TabAlignment.start,
153 dividerColor: Colors.transparent,
154 padding: EdgeInsets.zero,
155 tabs: [
156 Tab(text: S.of(context).wallets),
157 Tab(text: S.of(context).contact_list_contacts),
158 ],
159 ),
160 ),
161 ),
162 Expanded(
163 child: TabBarView(
164 controller: _tabController,
165 children: [
166 Observer(
167 builder: (final BuildContext context) => _buildWalletContacts(context),
168 ),
169 ContactListBody(
170 contactListViewModel: widget.contactListViewModel,
171 tabController: _tabController,
172 ),
173 ],
174 ),
175 ),
176 ],
177 ),
178 ),
179 );
180 }
181
182 Widget _buildWalletContacts(BuildContext context) {
183 final walletContacts = widget.contactListViewModel.walletContactsToShow;
184
185 final groupedContacts = <String, List<ContactBase>>{};
186 for (var contact in walletContacts) {
187 final baseName = _extractBaseName(contact.name);
188 groupedContacts.putIfAbsent(baseName, () => []).add(contact);
189 }
190
191 return ListView.builder(
192 padding: EdgeInsets.only(bottom: 128),
193 itemCount: groupedContacts.length * 2,
194 itemBuilder: (context, index) {
195 if (index.isOdd) {
196 return StandardListSeparator(height: 0);
197 } else {
198 final groupIndex = index ~/ 2;
199 final groupName = groupedContacts.keys.elementAt(groupIndex);
200 final groupContacts = groupedContacts[groupName]!;
201
202 if (groupContacts.length == 1) {
203 final contact = groupContacts[0];
204 return generateRaw(context, contact);
205 } else {
206 final activeContact = groupContacts.firstWhere(
207 (contact) => contact.name.contains('Active'),
208 orElse: () => groupContacts[0],
209 );
210
211 return Padding(
212 padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 4),
213 child: ExpansionTile(
214 title: Text(
215 groupName,
216 style: Theme.of(context).textTheme.bodyMedium!,
217 ),
218 leading: _buildCurrencyIcon(activeContact),
219 tilePadding: const EdgeInsets.only(left: 16, right: 16),
220 childrenPadding: const EdgeInsets.only(left: 16),
221 expandedCrossAxisAlignment: CrossAxisAlignment.start,
222 expandedAlignment: Alignment.topLeft,
223 backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
224 collapsedBackgroundColor: Theme.of(context).colorScheme.surfaceContainer,
225 collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
226 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
227 children: groupContacts.map((contact) => generateRaw(context, contact)).toList(),
228 ),
229 );
230 }
231 }
232 },
233 );
234 }
235
236 String _extractBaseName(String name) {
237 final bracketIndex = name.indexOf('(');
238 return (bracketIndex != -1) ? name.substring(0, bracketIndex).trim() : name;
239 }
240
241 Widget generateRaw(BuildContext context, ContactBase contact) {
242 final currencyIcon = _buildCurrencyIcon(contact);
243
244 return GestureDetector(
245 onTap: () async {
246 if (!widget.contactListViewModel.isEditable) {
247 Navigator.of(context).pop(contact);
248 return;
249 }
250
251 final isCopied = await DialogService.showNameAndAddressDialog(context, contact);
252
253 if (isCopied) {
254 await Clipboard.setData(ClipboardData(text: contact.address));
255 await showBar<void>(context, S.of(context).copied_to_clipboard);
256 }
257 },
258 behavior: HitTestBehavior.opaque,
259 child: Container(
260 decoration: BoxDecoration(
261 borderRadius: BorderRadius.all(Radius.circular(18)),
262 color: Theme.of(context).colorScheme.surfaceContainer,
263 ),
264 margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16),
265 padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16),
266 child: Row(
267 mainAxisSize: MainAxisSize.min,
268 mainAxisAlignment: MainAxisAlignment.start,
269 children: <Widget>[
270 currencyIcon,
271 Expanded(
272 child: Padding(
273 padding: EdgeInsets.only(left: 12),
274 child: Text(
275 contact.name,
276 style: Theme.of(context).textTheme.bodyMedium!,
277 ),
278 ),
279 ),
280 ],
281 ),
282 ),
283 );
284 }
285
286 Widget _buildCurrencyIcon(ContactBase contact) {
287 final image = (contact is WalletContact && contact.walletType != null)
288 ? getCryptoCurrencyIconForWalletListItem(contact.walletType!)
289 : contact.type.iconPath;
290 return (image != null && image.isNotEmpty)
291 ? CakeImageWidget(imageUrl: image, height: 24, width: 24)
292 : const SizedBox(height: 24, width: 24);
293 }
294 }
295
296 class ContactListBody extends StatefulWidget {
297 ContactListBody({required this.contactListViewModel, required this.tabController});
298
299 final ContactListViewModel contactListViewModel;
300 final TabController tabController;
301
302 @override
303 State<ContactListBody> createState() => _ContactListBodyState();
304 }
305
306 class _ContactListBodyState extends State<ContactListBody> {
307 bool _isContactsTabActive = false;
308
309 @override
310 void initState() {
311 super.initState();
312 widget.tabController.addListener(_handleTabChange);
313 }
314
315 void _handleTabChange() {
316 setState(() {
317 _isContactsTabActive = widget.tabController.index == 1;
318 });
319 }
320
321 @override
322 void dispose() {
323 widget.tabController.removeListener(_handleTabChange);
324 if (widget.contactListViewModel.settingsStore.contactListOrder == FilterListOrderType.Custom) {
325 widget.contactListViewModel.saveCustomOrder();
326 }
327 super.dispose();
328 }
329
330 @override
331 Widget build(BuildContext context) {
332 final contacts = widget.contactListViewModel.isEditable
333 ? widget.contactListViewModel.contacts
334 : widget.contactListViewModel.contactsToShow;
335 return Scaffold(
336 backgroundColor: Colors.transparent,
337 body: Container(
338 child: FilteredList(
339 list: contacts,
340 updateFunction: widget.contactListViewModel.reorderAccordingToContactList,
341 canReorder: widget.contactListViewModel.isEditable,
342 shrinkWrap: true,
343 padding: const EdgeInsets.only(bottom: 250),
344 itemBuilder: (context, index) {
345 final contact = contacts[index];
346 final contactContent =
347 generateContactRaw(context, contact, contacts.length == index + 1);
348 return GestureDetector(
349 key: Key('${contact.name}'),
350 onTap: () async {
351 if (!widget.contactListViewModel.isEditable) {
352 Navigator.of(context).pop(contact);
353 return;
354 }
355
356 final isCopied = await DialogService.showNameAndAddressDialog(context, contact);
357
358 if (isCopied) {
359 await Clipboard.setData(ClipboardData(text: contact.address));
360 await showBar<void>(context, S.of(context).copied_to_clipboard);
361 }
362 },
363 behavior: HitTestBehavior.opaque,
364 child: widget.contactListViewModel.isEditable
365 ? Slidable(
366 key: Key('${contact.key}'),
367 endActionPane: _actionPane(context, contact),
368 child: contactContent,
369 )
370 : contactContent,
371 );
372 },
373 ),
374 ),
375 floatingActionButton: _isContactsTabActive && widget.contactListViewModel.isEditable
376 ? filterButtonWidget(context, widget.contactListViewModel)
377 : null,
378 );
379 }
380
381 Widget generateContactRaw(BuildContext context, ContactRecord contact, bool isLast) {
382 final image = contact.type == CryptoCurrency.baseEth
383 ? 'assets/new-ui/crypto_full_icons/base.svg'
384 : contact.type.iconPath;
385 final currencyIcon = (image != null && image.isNotEmpty)
386 ? CakeImageWidget(imageUrl: image, height: 24, width: 24)
387 : const SizedBox(height: 24, width: 24);
388 return Column(
389 children: [
390 Container(
391 key: Key('${contact.name}'),
392 decoration: BoxDecoration(
393 borderRadius: BorderRadius.all(Radius.circular(18)),
394 color: Theme.of(context).colorScheme.surfaceContainer,
395 ),
396 margin: const EdgeInsets.only(top: 4, bottom: 4, left: 16, right: 16),
397 padding: const EdgeInsets.only(top: 16, bottom: 16, right: 16, left: 16),
398 child: Row(
399 mainAxisSize: MainAxisSize.min,
400 mainAxisAlignment: MainAxisAlignment.start,
401 children: <Widget>[
402 currencyIcon,
403 Expanded(
404 child: Padding(
405 padding: EdgeInsets.only(left: 12),
406 child: Text(
407 contact.name,
408 style: Theme.of(context).textTheme.bodyMedium!,
409 ),
410 ))
411 ],
412 ),
413 ),
414 ],
415 );
416 }
417
418 ActionPane _actionPane(BuildContext context, ContactRecord contact) {
419 return ActionPane(
420 motion: const ScrollMotion(),
421 extentRatio: 0.4,
422 children: [
423 SlidableAction(
424 onPressed: (_) async => await Navigator.of(context)
425 .pushNamed(Routes.addressBookAddContact, arguments: contact),
426 backgroundColor: Theme.of(context).colorScheme.primary,
427 foregroundColor: Theme.of(context).colorScheme.onSurface,
428 icon: Icons.edit,
429 label: S.of(context).edit,
430 ),
431 SlidableAction(
432 onPressed: (_) async {
433 final isDelete = await DialogService.showAlertDialog(context);
434
435 if (isDelete) {
436 await widget.contactListViewModel.delete(contact);
437 }
438 },
439 backgroundColor: Theme.of(context).colorScheme.error,
440 foregroundColor: Theme.of(context).colorScheme.onSurface,
441 icon: CupertinoIcons.delete,
442 label: S.of(context).delete,
443 ),
444 ],
445 );
446 }
447
448 Widget filterButtonWidget(BuildContext context, ContactListViewModel contactListViewModel) {
449 final filterIcon = Image.asset(
450 'assets/images/filter_icon.png',
451 color: Theme.of(context).colorScheme.onSurface,
452 );
453 return Padding(
454 padding: const EdgeInsets.only(bottom: FeatureFlag.hasNewUi ? 48 : 0),
455 child: MergeSemantics(
456 child: SizedBox(
457 height: 58,
458 width: 58,
459 child: ButtonTheme(
460 minWidth: double.minPositive,
461 child: Semantics(
462 container: true,
463 child: GestureDetector(
464 onTap: () async {
465 await showPopUp<void>(
466 context: context,
467 builder: (context) => FilterListWidget(
468 initalType: contactListViewModel.orderType,
469 initalAscending: contactListViewModel.ascending,
470 onClose: (bool ascending, FilterListOrderType type) async {
471 contactListViewModel.setAscending(ascending);
472 await contactListViewModel.setOrderType(type);
473 },
474 ),
475 );
476 },
477 child: Semantics(
478 label: 'Transaction Filter',
479 button: true,
480 enabled: true,
481 child: Container(
482 height: 36,
483 width: 36,
484 decoration: BoxDecoration(
485 shape: BoxShape.circle,
486 color: Theme.of(context).colorScheme.surfaceContainer,
487 ),
488 child: filterIcon,
489 ),
490 ),
491 ),
492 ),
493 ),
494 ),
495 ),
496 );
497 }
498 }
499
500 class DialogService {
501 static Future<bool> showAlertDialog(BuildContext context) async {
502 return await showPopUp<bool>(
503 context: context,
504 builder: (BuildContext context) {
505 return AlertWithTwoActions(
506 alertTitle: S.of(context).address_remove_contact,
507 alertContent: S.of(context).address_remove_content,
508 rightButtonText: S.of(context).remove,
509 leftButtonText: S.of(context).cancel,
510 actionRightButton: () => Navigator.of(context).pop(true),
511 actionLeftButton: () => Navigator.of(context).pop(false));
512 }) ??
513 false;
514 }
515
516 static Future<bool> showNameAndAddressDialog(BuildContext context, ContactBase contact) async {
517 return await showPopUp<bool>(
518 context: context,
519 builder: (BuildContext context) {
520 return AlertWithTwoActions(
521 alertTitle: contact.name,
522 alertContent: contact.address,
523 alertContentTextWidget: AddressFormatter.buildSegmentedAddress(
524 address: contact.address,
525 textAlign: TextAlign.center,
526 walletType: cryptoCurrencyOrTokenToWalletType(contact.type),
527 evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
528 fontSize: 16,
529 decoration: TextDecoration.none,
530 ),
531 ),
532 rightButtonText: S.of(context).copy,
533 leftButtonText: S.of(context).cancel,
534 actionRightButton: () => Navigator.of(context).pop(true),
535 actionLeftButton: () => Navigator.of(context).pop(false));
536 }) ??
537 false;
538 }
539 }