dev
dart 679 lines 22 KB
Raw
1 import 'package:cake_wallet/generated/i18n.dart';
2 import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart';
3 import 'package:cake_wallet/new-ui/widgets/currency_picker/chain_chip_strip.dart';
4 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart';
5 import 'package:cake_wallet/new-ui/widgets/currency_picker/picker_recents_loader.dart';
6 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_list_container.dart';
7 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart';
8 import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart';
9 import 'package:cake_wallet/new-ui/widgets/currency_picker/picker_section_header.dart';
10 import 'package:cake_wallet/new-ui/widgets/currency_picker/pill_grid.dart';
11 import 'package:cake_wallet/new-ui/widgets/currency_picker/select_network_page.dart';
12 import 'package:cake_wallet/wallet_types.g.dart';
13 import 'package:cw_core/crypto_currency.dart';
14 import 'package:cw_core/currency_for_wallet_type.dart';
15 import 'package:cw_core/currency_groups.dart';
16 import 'package:cw_core/utils/print_verbose.dart';
17 import 'package:cw_core/wallet_type.dart';
18 import 'package:flutter/material.dart';
19
20 class MultiNetworkCurrencyPicker extends StatefulWidget {
21 const MultiNetworkCurrencyPicker({super.key, required this.args});
22
23 final CurrencyPickerArgs args;
24
25 @override
26 State<MultiNetworkCurrencyPicker> createState() => _MultiNetworkCurrencyPickerState();
27 }
28
29 class _MultiNetworkCurrencyPickerState extends State<MultiNetworkCurrencyPicker> {
30 bool _recentsLoaded = false;
31 WalletType? _selectedNetwork;
32 List<CryptoCurrency> _recents = const [];
33 final TextEditingController _searchController = TextEditingController();
34
35 bool get _isSearching => _searchController.text.trim().isNotEmpty;
36
37 late final List<WalletType> _networks = _computeNetworks();
38
39 List<WalletType> _computeNetworks() {
40 final counts = <WalletType, int>{};
41 for (final currency in widget.args.items) {
42 final walletType = cryptoCurrencyOrTokenToWalletType(currency);
43 if (walletType == null) continue;
44 counts[walletType] = (counts[walletType] ?? 0) + 1;
45 }
46 return counts.entries.where((e) => e.value >= 2).map((e) => e.key).toList(growable: false);
47 }
48
49 Set<CryptoCurrency> get _natives =>
50 {for (final walletType in availableWalletTypes) walletTypeToCryptoCurrency(walletType)};
51
52 @override
53 void initState() {
54 super.initState();
55 _searchController.addListener(() => setState(() {}));
56 _loadRecents();
57 }
58
59 @override
60 void dispose() {
61 _searchController.dispose();
62 super.dispose();
63 }
64
65 Future<void> _loadRecents() async {
66 try {
67 final recents = await PickerRecentsLoader.load(
68 source: widget.args.recentsSource,
69 visibleItems: widget.args.items,
70 );
71 if (!mounted) return;
72 setState(() {
73 _recents = recents;
74 _recentsLoaded = true;
75 });
76 } catch (e, s) {
77 printV('load picker recents failed: $e\n$s');
78 if (!mounted) return;
79 setState(() => _recentsLoaded = true);
80 }
81 }
82
83 bool _matchesNetwork(CryptoCurrency c) {
84 if (_selectedNetwork == null) return true;
85 return cryptoCurrencyOrTokenToWalletType(c) == _selectedNetwork;
86 }
87
88 List<CryptoCurrency> get _visibleItems {
89 final query = _searchController.text.trim();
90 return widget.args.items
91 .where(_hasSupportedChain)
92 .where((c) => currencyMatchesQuery(c, query) && _matchesNetwork(c))
93 .toList(growable: false);
94 }
95
96 bool _hasSupportedChain(CryptoCurrency c) {
97 if (c.tag == null) return true;
98 return cryptoCurrencyOrTokenToWalletType(c) != null;
99 }
100
101 void _selectCurrency(CryptoCurrency currency) {
102 widget.args.onSelected(currency);
103 Navigator.of(context).maybePop();
104 }
105
106 void _onStablecoinPillTapped(CryptoCurrency tapped) {
107 var variants = widget.args.items
108 .where((c) =>
109 isTrustedStablecoin(c) &&
110 c.title.toUpperCase() == tapped.title.toUpperCase() &&
111 cryptoCurrencyOrTokenToWalletType(c) != null)
112 .toList();
113
114 if (_selectedNetwork != null) {
115 final filtered = variants
116 .where((c) => cryptoCurrencyOrTokenToWalletType(c) == _selectedNetwork)
117 .toList(growable: false);
118 if (filtered.isNotEmpty) variants = filtered;
119 }
120
121 if (variants.length <= 1) {
122 _selectCurrency(variants.isNotEmpty ? variants.first : tapped);
123 return;
124 }
125
126 Navigator.of(context).push(
127 PageRouteBuilder<void>(
128 opaque: true,
129 transitionDuration: const Duration(milliseconds: 250),
130 reverseTransitionDuration: const Duration(milliseconds: 250),
131 pageBuilder: (_, __, ___) => SelectNetworkPage(
132 assetTitle: tapped.title,
133 assetFullName: tapped.fullName,
134 assetIconPath: tapped.iconPath,
135 variants: variants,
136 onSelected: widget.args.onSelected,
137 ),
138 transitionsBuilder: (_, animation, __, child) => SlideTransition(
139 position: animation.drive(
140 Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
141 .chain(CurveTween(curve: Curves.easeInOut)),
142 ),
143 child: child,
144 ),
145 ),
146 );
147 }
148
149 @override
150 Widget build(BuildContext context) {
151 return Column(
152 mainAxisSize: MainAxisSize.max,
153 children: [
154 if (_networks.length > 1)
155 ChainChipStrip(
156 walletTypes: _networks,
157 selected: _selectedNetwork,
158 onSelected: (network) => setState(() => _selectedNetwork = network),
159 ),
160 Expanded(
161 child: _MultiNetworkPickerBody(
162 items: _visibleItems,
163 isSearching: _isSearching,
164 recents: _recents,
165 recentsLoaded: _recentsLoaded,
166 natives: _natives,
167 selected: widget.args.selected,
168 symbolResolver: widget.args.symbolResolver,
169 onSelect: _selectCurrency,
170 onStablecoinTap: _onStablecoinPillTapped,
171 ),
172 ),
173 CurrencyPickerSearchField(
174 controller: _searchController,
175 hintText: S.of(context).search,
176 ),
177 ],
178 );
179 }
180 }
181
182 class _MultiNetworkPickerBody extends StatefulWidget {
183 const _MultiNetworkPickerBody({
184 required this.items,
185 required this.isSearching,
186 required this.recents,
187 required this.recentsLoaded,
188 required this.natives,
189 required this.selected,
190 required this.symbolResolver,
191 required this.onSelect,
192 required this.onStablecoinTap,
193 });
194
195 final bool isSearching;
196 final bool recentsLoaded;
197 final CryptoCurrency? selected;
198 final List<CryptoCurrency> items;
199 final List<CryptoCurrency> recents;
200 final Set<CryptoCurrency> natives;
201 final void Function(CryptoCurrency) onSelect;
202 final String Function(CryptoCurrency) symbolResolver;
203 final void Function(CryptoCurrency) onStablecoinTap;
204
205 @override
206 State<_MultiNetworkPickerBody> createState() => _MultiNetworkPickerBodyState();
207 }
208
209 class _MultiNetworkPickerBodyState extends State<_MultiNetworkPickerBody> {
210 static const int _previewCount = 3;
211
212 final ScrollController _scrollController = ScrollController();
213 bool _moreCryptosSectionExpanded = false;
214 bool _xstocksSectionExpanded = false;
215
216 @override
217 void initState() {
218 super.initState();
219 final selected = widget.selected;
220 if (selected == null) return;
221
222 final moreCryptos = _computeMoreCryptosSection(widget.items);
223 final xstocks = _computeXstocksSection(widget.items);
224
225 if (moreCryptos.indexOf(selected) >= _previewCount) {
226 _moreCryptosSectionExpanded = true;
227 }
228
229 if (xstocks.indexOf(selected) >= _previewCount) {
230 _xstocksSectionExpanded = true;
231 }
232 }
233
234 @override
235 void dispose() {
236 _scrollController.dispose();
237 super.dispose();
238 }
239
240 List<CryptoCurrency> _computeMoreCryptosSection(List<CryptoCurrency> from) {
241 final list = from
242 .where((c) =>
243 !widget.natives.contains(c) &&
244 cryptoCurrencyOrTokenToWalletType(c) == null &&
245 c.tag == null &&
246 !c.groups.contains(CurrencyGroups.stablecoin) &&
247 !c.groups.contains(CurrencyGroups.tokenizedStock))
248 .toList();
249
250 list.sort((a, b) => (a.fullName ?? a.title).toLowerCase().compareTo(
251 (b.fullName ?? b.title).toLowerCase(),
252 ));
253
254 return list;
255 }
256
257 List<CryptoCurrency> _computeXstocksSection(List<CryptoCurrency> from) {
258 return from
259 .where((c) => c.groups.contains(CurrencyGroups.tokenizedStock))
260 .toList(growable: false);
261 }
262
263 @override
264 Widget build(BuildContext context) {
265 final items = widget.items;
266 final isSearching = widget.isSearching;
267 final recents = widget.recents;
268 final recentsLoaded = widget.recentsLoaded;
269 final natives = widget.natives;
270 final selected = widget.selected;
271 final symbolResolver = widget.symbolResolver;
272 final onSelect = widget.onSelect;
273 final onStablecoinTap = widget.onStablecoinTap;
274
275 if (items.isEmpty) {
276 return Center(
277 child: Padding(
278 padding: const EdgeInsets.all(24),
279 child: Text(
280 S.of(context).picker_no_matches,
281 textAlign: TextAlign.center,
282 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
283 color: Theme.of(context).colorScheme.onSurfaceVariant,
284 ),
285 ),
286 ),
287 );
288 }
289
290 if (isSearching) {
291 return NotificationListener<ScrollNotification>(
292 onNotification: (_) => true,
293 child: ListView(
294 controller: _scrollController,
295 primary: false,
296 physics: const ClampingScrollPhysics(),
297 padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
298 children: [
299 CurrencyPickerListContainer(
300 rows: [
301 for (final item in items)
302 CurrencyPickerRow(
303 currency: item,
304 isSelected: selected != null && selected == item,
305 chainPillLabel: _chainPillLabelFor(item),
306 chainBadgePath: _chainBadgePathFor(item),
307 trailing: _SymbolTrailing(
308 currency: item,
309 symbolResolver: symbolResolver,
310 ),
311 onTap: () => onSelect(item),
312 ),
313 ],
314 ),
315 ],
316 ),
317 );
318 }
319
320 final visibleRecents = recents.where(items.contains).toList(growable: false);
321
322 final seenStablecoinTitles = <String>{};
323 final stablecoins = items
324 .where((c) => isTrustedStablecoin(c) && seenStablecoinTitles.add(c.title.toUpperCase()))
325 .toList(growable: false);
326
327 final cryptocurrencies = items.where(natives.contains).toList(growable: false);
328 final moreCryptos = _computeMoreCryptosSection(items);
329 final xstocks = _computeXstocksSection(items);
330 final allAssets = [...items]..sort((a, b) => (a.fullName ?? a.title).toLowerCase().compareTo(
331 (b.fullName ?? b.title).toLowerCase(),
332 ));
333
334 final section = _selectedSection(
335 recents: visibleRecents,
336 stablecoins: stablecoins,
337 cryptocurrencies: cryptocurrencies,
338 moreCryptos: moreCryptos,
339 xstocks: xstocks,
340 );
341
342 final moreCryptosVisible = _moreCryptosSectionExpanded
343 ? moreCryptos
344 : moreCryptos.take(_previewCount).toList(growable: false);
345 final xstocksVisible =
346 _xstocksSectionExpanded ? xstocks : xstocks.take(_previewCount).toList(growable: false);
347
348 return NotificationListener<ScrollNotification>(
349 onNotification: (_) => true,
350 child: ListView(
351 controller: _scrollController,
352 primary: false,
353 physics: const ClampingScrollPhysics(),
354 padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
355 children: [
356 if (recentsLoaded && visibleRecents.isNotEmpty)
357 _PickerSection(
358 title: S.of(context).picker_section_recents,
359 child: _RecentsRow(
360 items: visibleRecents,
361 selected: section == _SelSection.recents ? selected : null,
362 symbolResolver: symbolResolver,
363 onTap: onSelect,
364 ),
365 ),
366 if (stablecoins.isNotEmpty)
367 _PickerSection(
368 title: S.of(context).picker_section_stablecoins,
369 child: PillGrid(
370 items: stablecoins,
371 selected: section == _SelSection.stablecoins ? selected : null,
372 onTap: onStablecoinTap,
373 symbolResolver: symbolResolver,
374 ),
375 ),
376 if (cryptocurrencies.isNotEmpty)
377 _PickerSection(
378 title: S.of(context).picker_section_cryptocurrencies,
379 child: CurrencyPickerListContainer(
380 rows: [
381 for (final item in cryptocurrencies)
382 CurrencyPickerRow(
383 currency: item,
384 isSelected: section == _SelSection.cryptocurrencies && selected == item,
385 chainPillLabel: _chainPillLabelFor(item),
386 chainBadgePath: _chainBadgePathFor(item),
387 trailing: _SymbolTrailing(
388 currency: item,
389 symbolResolver: symbolResolver,
390 ),
391 onTap: () => onSelect(item),
392 ),
393 ],
394 ),
395 ),
396 if (moreCryptos.isNotEmpty)
397 _PickerSection(
398 title: S.of(context).picker_section_more_cryptocurrencies,
399 child: CurrencyPickerListContainer(
400 rows: [
401 for (final item in moreCryptosVisible)
402 CurrencyPickerRow(
403 currency: item,
404 isSelected: section == _SelSection.moreCryptocurrencies && selected == item,
405 trailing: _SymbolTrailing(
406 currency: item,
407 symbolResolver: symbolResolver,
408 ),
409 onTap: () => onSelect(item),
410 ),
411 if (moreCryptos.length > _previewCount)
412 _SeeAllRow(
413 expanded: _moreCryptosSectionExpanded,
414 onTap: () => setState(
415 () => _moreCryptosSectionExpanded = !_moreCryptosSectionExpanded),
416 ),
417 ],
418 ),
419 ),
420 if (xstocks.isNotEmpty)
421 _PickerSection(
422 title: S.of(context).picker_section_tokenized_stocks,
423 child: CurrencyPickerListContainer(
424 rows: [
425 for (final item in xstocksVisible)
426 CurrencyPickerRow(
427 currency: item,
428 isSelected: section == _SelSection.xstocks && selected == item,
429 trailing: _SymbolTrailing(
430 currency: item,
431 symbolResolver: symbolResolver,
432 ),
433 onTap: () => onSelect(item),
434 chainPillLabel: _chainPillLabelFor(item),
435 chainBadgePath: _chainBadgePathFor(item),
436 ),
437 if (xstocks.length > _previewCount)
438 _SeeAllRow(
439 expanded: _xstocksSectionExpanded,
440 onTap: () =>
441 setState(() => _xstocksSectionExpanded = !_xstocksSectionExpanded),
442 ),
443 ],
444 ),
445 ),
446 if (allAssets.isNotEmpty)
447 _PickerSection(
448 title: S.of(context).picker_section_all_assets,
449 child: CurrencyPickerListContainer(
450 rows: [
451 for (final item in allAssets)
452 CurrencyPickerRow(
453 currency: item,
454 isSelected: section == _SelSection.allAssets && selected == item,
455 chainPillLabel: _chainPillLabelFor(item),
456 chainBadgePath: _chainBadgePathFor(item),
457 trailing: _SymbolTrailing(
458 currency: item,
459 symbolResolver: symbolResolver,
460 ),
461 onTap: () => onSelect(item),
462 ),
463 ],
464 ),
465 ),
466 ],
467 ),
468 );
469 }
470
471 String? _chainPillLabelFor(CryptoCurrency c) {
472 if (c == CryptoCurrency.btcln) return _shortChainLabel(c);
473 if (_isL2NativeEth(c)) return _shortChainLabel(c);
474 if (widget.natives.contains(c)) return null;
475 if (cryptoCurrencyOrTokenToWalletType(c) == null) return null;
476 return _shortChainLabel(c);
477 }
478
479 String _shortChainLabel(CryptoCurrency c) {
480 if (cryptoCurrencyOrTokenToWalletType(c) == WalletType.bsc) return 'BSC';
481 return chainNameForCurrency(c);
482 }
483
484 String? _chainBadgePathFor(CryptoCurrency c) {
485 if (_isL2NativeEth(c)) return c.chainIconPath;
486 if (widget.natives.contains(c)) return null;
487 final wt = cryptoCurrencyOrTokenToWalletType(c);
488 if (wt == null) return null;
489 return c.chainIconPath ?? walletTypeToCryptoCurrency(wt).chainIconPath;
490 }
491
492 bool _isL2NativeEth(CryptoCurrency c) =>
493 c == CryptoCurrency.arbEth || c == CryptoCurrency.baseEth;
494
495 _SelSection? _selectedSection({
496 required List<CryptoCurrency> recents,
497 required List<CryptoCurrency> stablecoins,
498 required List<CryptoCurrency> cryptocurrencies,
499 required List<CryptoCurrency> moreCryptos,
500 required List<CryptoCurrency> xstocks,
501 }) {
502 final s = widget.selected;
503 if (s == null) return null;
504 if (recents.contains(s)) return _SelSection.recents;
505 if (stablecoins.any((c) => c.title.toUpperCase() == s.title.toUpperCase())) {
506 return _SelSection.stablecoins;
507 }
508 if (cryptocurrencies.contains(s)) return _SelSection.cryptocurrencies;
509 if (moreCryptos.contains(s)) return _SelSection.moreCryptocurrencies;
510 if (xstocks.contains(s)) return _SelSection.xstocks;
511 return _SelSection.allAssets;
512 }
513 }
514
515 class _PickerSection extends StatelessWidget {
516 const _PickerSection({required this.title, required this.child});
517
518 final String title;
519 final Widget child;
520
521 @override
522 Widget build(BuildContext context) {
523 return Column(
524 crossAxisAlignment: CrossAxisAlignment.start,
525 children: [
526 PickerSectionHeader(title: title),
527 child,
528 const SizedBox(height: 16),
529 ],
530 );
531 }
532 }
533
534 class _SymbolTrailing extends StatelessWidget {
535 const _SymbolTrailing({
536 required this.currency,
537 required this.symbolResolver,
538 });
539
540 final CryptoCurrency currency;
541 final String Function(CryptoCurrency) symbolResolver;
542
543 @override
544 Widget build(BuildContext context) {
545 return Text(
546 symbolResolver(currency),
547 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
548 fontWeight: FontWeight.w500,
549 color: Theme.of(context).colorScheme.onSurfaceVariant,
550 ),
551 );
552 }
553 }
554
555 class _SeeAllRow extends StatelessWidget {
556 const _SeeAllRow({
557 required this.expanded,
558 required this.onTap,
559 });
560
561 final bool expanded;
562 final VoidCallback onTap;
563
564 @override
565 Widget build(BuildContext context) {
566 final colors = Theme.of(context).colorScheme;
567 final label = expanded ? S.of(context).picker_show_less : S.of(context).picker_see_all;
568 return InkWell(
569 onTap: onTap,
570 child: Padding(
571 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
572 child: Row(
573 children: [
574 Expanded(
575 child: Text(
576 label,
577 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
578 fontWeight: FontWeight.w500,
579 color: colors.primary,
580 ),
581 ),
582 ),
583 ],
584 ),
585 ),
586 );
587 }
588 }
589
590 class _RecentsRow extends StatelessWidget {
591 const _RecentsRow({
592 required this.items,
593 required this.selected,
594 required this.symbolResolver,
595 required this.onTap,
596 });
597
598 final List<CryptoCurrency> items;
599 final CryptoCurrency? selected;
600 final String Function(CryptoCurrency) symbolResolver;
601 final void Function(CryptoCurrency) onTap;
602
603 @override
604 Widget build(BuildContext context) {
605 return SizedBox(
606 height: 44,
607 child: ListView.separated(
608 scrollDirection: Axis.horizontal,
609 itemCount: items.length,
610 separatorBuilder: (_, __) => const SizedBox(width: 8),
611 itemBuilder: (_, i) {
612 final item = items[i];
613 return _RecentPill(
614 currency: item,
615 label: symbolResolver(item),
616 isSelected: selected != null && selected == item,
617 onTap: () => onTap(item),
618 );
619 },
620 ),
621 );
622 }
623 }
624
625 class _RecentPill extends StatelessWidget {
626 const _RecentPill({
627 required this.currency,
628 required this.label,
629 required this.isSelected,
630 required this.onTap,
631 });
632
633 final CryptoCurrency currency;
634 final String label;
635 final bool isSelected;
636 final VoidCallback onTap;
637
638 @override
639 Widget build(BuildContext context) {
640 final colors = Theme.of(context).colorScheme;
641 return InkWell(
642 onTap: onTap,
643 borderRadius: BorderRadius.circular(80),
644 child: Container(
645 padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
646 decoration: BoxDecoration(
647 color: colors.surfaceContainer,
648 borderRadius: BorderRadius.circular(80),
649 border: isSelected ? Border.all(color: colors.primary, width: 1.5) : null,
650 ),
651 child: Row(
652 mainAxisSize: MainAxisSize.min,
653 children: [
654 TokenImageWidget(
655 imageUrl: currency.iconPath ?? '',
656 size: 24,
657 ),
658 const SizedBox(width: 8),
659 Text(
660 label,
661 style: Theme.of(context).textTheme.bodyMedium?.copyWith(
662 fontWeight: FontWeight.w600,
663 ),
664 ),
665 ],
666 ),
667 ),
668 );
669 }
670 }
671
672 enum _SelSection {
673 recents,
674 stablecoins,
675 cryptocurrencies,
676 moreCryptocurrencies,
677 xstocks,
678 allAssets,
679 }