dev
dart 512 lines 17.5 KB
Raw
1 import 'package:cake_wallet/cake_pay/src/cake_pay_states.dart';
2 import 'package:cake_wallet/cake_pay/src/models/cake_pay_card.dart';
3 import 'package:cake_wallet/cake_pay/src/widgets/cake_pay_search_bar_widget.dart';
4 import 'package:cake_wallet/cake_pay/src/widgets/user_card_item.dart';
5 import 'package:cake_wallet/entities/country.dart';
6 import 'package:cake_wallet/generated/i18n.dart';
7 import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
8 import 'package:cake_wallet/routes.dart';
9 import 'package:cake_wallet/src/screens/base_page.dart';
10 import 'package:cake_wallet/cake_pay/src/widgets/card_item.dart';
11 import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart';
12 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
13 import 'package:cake_wallet/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart';
14 import 'package:cake_wallet/src/widgets/gradient_background.dart';
15 import 'package:cake_wallet/src/widgets/picker.dart';
16 import 'package:cake_wallet/src/widgets/tab_view_wrapper_widget.dart';
17 import 'package:cake_wallet/typography.dart';
18 import 'package:cake_wallet/utils/feature_flag.dart';
19 import 'package:cake_wallet/utils/responsive_layout_util.dart';
20 import 'package:cake_wallet/utils/show_pop_up.dart';
21 import 'package:cake_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart';
22 import 'package:flutter/material.dart';
23 import 'package:flutter_mobx/flutter_mobx.dart';
24 import 'package:mobx/mobx.dart';
25
26 class CakePayCardsPage extends BasePage {
27 CakePayCardsPage(this._cardsListViewModel);
28
29 final CakePayCardsListViewModel _cardsListViewModel;
30
31 @override
32 bool get gradientBackground => true;
33
34 @override
35 Widget Function(BuildContext, Widget) get rootWrapper =>
36 (BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold);
37
38 @override
39 bool get resizeToAvoidBottomInset => false;
40
41 @override
42 Widget middle(BuildContext context) {
43 return Text(
44 'Cake Pay',
45 style: textMediumSemiBold(
46 color: titleColor(context),
47 ),
48 );
49 }
50
51 @override
52 Widget trailing(BuildContext context) {
53 return Observer(builder: (_) {
54 final loggedIn = _cardsListViewModel.isUserAuthenticated == true;
55
56 Future<void> _handleOnPressed() async {
57 if (loggedIn) {
58 Navigator.pushNamed(context, Routes.cakePayAccountPage);
59 return;
60 }
61 final success = await Navigator.pushNamed<bool>(context, Routes.cakePayWelcomePage);
62
63 if (success == true) await _cardsListViewModel.checkAuth();
64 }
65
66 if (!loggedIn || _cardsListViewModel.username == null) {
67 return _TrailingIcon(
68 asset: 'assets/images/profile.png',
69 iconColor: pageIconColor(context) ?? Colors.white,
70 onPressed: () async => await _handleOnPressed(),
71 );
72 }
73 final letter = _cardsListViewModel.username!.trim()[0].toUpperCase();
74 return IconButton(
75 padding: EdgeInsets.zero,
76 iconSize: 25,
77 onPressed: _handleOnPressed,
78 icon: CircleAvatar(
79 radius: 12,
80 backgroundColor: Theme.of(context).colorScheme.primary,
81 child: Text(
82 letter,
83 style: TextStyle(
84 color: Theme.of(context).colorScheme.onPrimary,
85 fontWeight: FontWeight.w600,
86 ),
87 ),
88 ),
89 );
90 });
91 }
92
93 @override
94 Widget body(BuildContext context) {
95 return KeyboardHideOverlay(
96 child:
97 CakePayCardsPageBody(cardsListViewModel: _cardsListViewModel, titleColor: titleColor));
98 }
99 }
100
101 class CakePayCardsPageBody extends StatefulWidget {
102 const CakePayCardsPageBody({
103 super.key,
104 required CakePayCardsListViewModel cardsListViewModel,
105 required this.titleColor,
106 }) : _cardsListViewModel = cardsListViewModel;
107
108 final CakePayCardsListViewModel _cardsListViewModel;
109 final Color? Function(BuildContext) titleColor;
110
111 @override
112 State<CakePayCardsPageBody> createState() => _CakePayCardsPageBodyState();
113 }
114
115 class _CakePayCardsPageBodyState extends State<CakePayCardsPageBody> {
116 ReactionDisposer? _countryPickerDisposer;
117
118 @override
119 void initState() {
120 super.initState();
121 final viewModel = widget._cardsListViewModel;
122
123 _countryPickerDisposer = when(
124 (_) => viewModel.shouldShowCountryPicker,
125 () async {
126 viewModel.storeInitialFilterStates();
127
128 WidgetsBinding.instance.addPostFrameCallback(
129 (_) async {
130 await showCountryPicker(context, viewModel);
131 if (viewModel.hasFiltersChanged) {
132 viewModel.resetLoadingNextPageState();
133 viewModel.getVendors();
134 }
135 viewModel.settingsStore.selectedCakePayCountry = viewModel.selectedCountry;
136 },
137 );
138 },
139 );
140 }
141
142 @override
143 void dispose() {
144 _countryPickerDisposer?.call();
145 super.dispose();
146 }
147
148 @override
149 Widget build(BuildContext context) {
150 return Observer(builder: (_) {
151 final isUserAuthenticated = widget._cardsListViewModel.isUserAuthenticated;
152
153 if (isUserAuthenticated == null) return const _Loading();
154
155 if (isUserAuthenticated == false || !FeatureFlag.isCakePayRedemptionFlowEnabled) {
156 return Padding(
157 padding: const EdgeInsets.symmetric(horizontal: 14),
158 child: _ShopTab(cardsListViewModel: widget._cardsListViewModel),
159 );
160 }
161
162 final titleColor = widget.titleColor(context);
163
164 return Padding(
165 padding: const EdgeInsets.symmetric(horizontal: 14),
166 child: Column(children: [
167 Expanded(
168 child: TabViewWrapper(
169 labelStyle: TextStyle(
170 color: titleColor,
171 fontFamily: 'Lato',
172 fontSize: 20,
173 fontWeight: FontWeight.w600),
174 unselectedLabelStyle: TextStyle(
175 color: titleColor?.withAlpha(150) ?? Colors.white70,
176 fontFamily: 'Lato',
177 fontSize: 20,
178 fontWeight: FontWeight.w400),
179 indicatorColor: titleColor,
180 tabs: const [
181 Tab(text: 'My Cards'),
182 Tab(text: 'Shop')
183 ],
184 views: [
185 _MyCardsTab(
186 cardsListViewModel: widget._cardsListViewModel,
187 ),
188 _ShopTab(cardsListViewModel: widget._cardsListViewModel)
189 ]),
190 )
191 ]));
192 });
193 }
194 }
195
196 Future<void> showFilterWidget(
197 BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
198 return showPopUp<void>(
199 context: context,
200 builder: (BuildContext context) {
201 return FilterWidget(filterItems: cardsListViewModel.createFilterItems);
202 },
203 );
204 }
205
206 Future<void> showCountryPicker(
207 BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
208 await showPopUp<void>(
209 context: context,
210 builder: (_) => Picker(
211 title: S.of(context).select_your_country,
212 items: cardsListViewModel.availableCountries,
213 images: cardsListViewModel.availableCountries
214 .map((e) => Image.asset(
215 e.iconPath,
216 errorBuilder: (context, error, stackTrace) => Container(
217 width: 58,
218 height: 58,
219 ),
220 ))
221 .toList(),
222 selectedAtIndex:
223 cardsListViewModel.availableCountries.indexOf(cardsListViewModel.selectedCountry),
224 onItemSelected: (Country country) => cardsListViewModel.setSelectedCountry(country),
225 isSeparated: false,
226 hintText: S.of(context).search,
227 matchingCriteria: (Country country, String searchText) =>
228 country.fullName.toLowerCase().contains(searchText.toLowerCase())));
229 }
230
231 class _TrailingIcon extends StatelessWidget {
232 const _TrailingIcon({required this.asset, this.onPressed, required this.iconColor});
233
234 final String asset;
235 final VoidCallback? onPressed;
236 final Color iconColor;
237
238 @override
239 Widget build(BuildContext context) {
240 return Semantics(
241 label: S.of(context).profile,
242 child: Material(
243 color: Colors.transparent,
244 child: IconButton(
245 padding: EdgeInsets.zero,
246 constraints: BoxConstraints(),
247 highlightColor: Colors.transparent,
248 onPressed: onPressed,
249 icon: ImageIcon(AssetImage(asset), size: 25, color: iconColor),
250 ),
251 ));
252 }
253 }
254
255 class _MyCardsTab extends StatefulWidget {
256 const _MyCardsTab({required this.cardsListViewModel});
257
258 final CakePayCardsListViewModel cardsListViewModel;
259
260 @override
261 State<_MyCardsTab> createState() => _MyCardsTabState();
262 }
263
264 class _MyCardsTabState extends State<_MyCardsTab> {
265 late final TextEditingController _searchController;
266
267 @override
268 void initState() {
269 super.initState();
270
271 _searchController = TextEditingController(
272 text: widget.cardsListViewModel.searchMyCardsString,
273 );
274 }
275
276 @override
277 void dispose() {
278 super.dispose();
279 }
280
281 @override
282 Widget build(BuildContext context) {
283 final viewModel = widget.cardsListViewModel;
284 return Observer(builder: (_) {
285 return Column(
286 children: [
287 Padding(
288 padding: const EdgeInsets.fromLTRB(2, 6, 0, 6),
289 child: CakePaySearchBar(
290 initialQuery: viewModel.searchMyCardsString,
291 controller: _searchController,
292 onSearch: viewModel.setMyCardsQuery,
293 onFilter: () async {}, // TODO: implement filter
294 )),
295 Expanded(
296 child: Observer(builder: (_) {
297 final cards = viewModel.filteredUserCards;
298 if (viewModel.userCardState is UserCakePayCardsStateFetching) return const _Loading();
299 if (viewModel.userCardState is UserCakePayCardsStateNoCards)
300 return Center(child: Text(S.of(context).no_cards_found));
301
302 final showThumb = cards.length > 6;
303 final userCardsList = Stack(
304 children: [
305 GridView.builder(
306 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
307 childAspectRatio: 1.25,
308 crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 3 : 2,
309 crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5,
310 mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5),
311 padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22),
312 itemCount: cards.length,
313 itemBuilder: (_, i) {
314 final card = cards[i];
315 return UserCardItem(
316 logoUrl: card.cardImageUrl,
317 title: card.name,
318 subTitle: '\$100',
319 onTap: () => _showCardInfoBottomSheet(context, card),
320 );
321 },
322 ),
323 ],
324 );
325 return showThumb
326 ? Scrollbar(
327 key: ValueKey('cake_pay_my_cards_tab_scrollbar_key'),
328 thumbVisibility: true,
329 trackVisibility: true,
330 child: userCardsList,
331 )
332 : userCardsList;
333 }),
334 ),
335 ],
336 );
337 });
338 }
339 }
340
341 Future<void> _showCardInfoBottomSheet(BuildContext context, CakePayCard card) async {
342 bool isReloadable = false; // TODO: replace with real logic
343 if (card.name.toLowerCase().contains('prepaid')) {
344 isReloadable = true;
345 }
346 await showModalBottomSheet<void>(
347 context: context,
348 isDismissible: false,
349 isScrollControlled: true,
350 backgroundColor: Colors.transparent,
351 builder: (BuildContext bottomSheetContext) {
352 return isReloadable
353 ? CakePayCardInfoBottomSheet(
354 isReloadable: isReloadable,
355 titleText: 'Reloadable Card',
356 balance: '100 USD',
357 howToUse: card.howToUse,
358 footerType: FooterType.doubleActionButton,
359 applyBoxShadow: true,
360 contentImage: card.cardImageUrl,
361 leftActionButtonKey: const Key('cake_pay_cards_page_reload_card_left_button_key'),
362 doubleActionLeftButtonText: 'Archive',
363 onLeftActionButtonPressed: () {},
364 rightActionButtonKey: const Key('cake_pay_cards_page_reload_card_right_button_key'),
365 doubleActionRightButtonText: 'Top Up',
366 onRightActionButtonPressed: () {},
367 onUpdateBalancePressed: () {})
368 : CakePayCardInfoBottomSheet(
369 isReloadable: isReloadable,
370 titleText: card.name,
371 balance: '500 USD',
372 howToUse: card.howToUse,
373 footerType: FooterType.singleActionButton,
374 applyBoxShadow: true,
375 contentImage: card.cardImageUrl,
376 singleActionButtonKey: const Key('cake_pay_cards_page_card_info_bottom_sheet_key'),
377 singleActionButtonText: 'Mark As Used',
378 onSingleActionButtonPressed: () {},
379 onUpdateBalancePressed: () {});
380 },
381 );
382 }
383
384 class _ShopTab extends StatefulWidget {
385 const _ShopTab({required this.cardsListViewModel});
386
387 final CakePayCardsListViewModel cardsListViewModel;
388
389 @override
390 State<_ShopTab> createState() => _ShopTabState();
391 }
392
393 class _ShopTabState extends State<_ShopTab> {
394 late final ScrollController _scroll;
395
396 @override
397 void initState() {
398 super.initState();
399 _scroll = ScrollController()
400 ..addListener(() {
401 if (!_scroll.hasClients) return;
402 final max = _scroll.position.maxScrollExtent;
403
404 final threshold = 200.0;
405 if (_scroll.offset >= max - threshold && !_scroll.position.outOfRange) {
406 widget.cardsListViewModel.fetchNextPage();
407 }
408 });
409 }
410
411 @override
412 void dispose() {
413 _scroll.dispose();
414 super.dispose();
415 }
416
417 @override
418 Widget build(BuildContext context) {
419 final viewModel = widget.cardsListViewModel;
420
421 return Column(
422 children: [
423 Padding(
424 padding: const EdgeInsets.fromLTRB(2, 6, 0, 6),
425 child: CakePaySearchBar(
426 initialQuery: viewModel.searchString,
427 onSearch: (String searchText) {
428 if (searchText != viewModel.searchString) {
429 viewModel.searchString = searchText;
430 viewModel.resetLoadingNextPageState();
431 viewModel.getVendors(text: searchText);
432 }
433 },
434 onFilter: () async {
435 viewModel.storeInitialFilterStates();
436 await showFilterWidget(context, viewModel);
437 if (viewModel.hasFiltersChanged) {
438 viewModel.resetLoadingNextPageState();
439 viewModel.getVendors(text: viewModel.searchString);
440 }
441 },
442 onCountryPick: () async {
443 viewModel.storeInitialFilterStates();
444 await showCountryPicker(context, viewModel);
445 if (viewModel.hasFiltersChanged) {
446 viewModel.resetLoadingNextPageState();
447 viewModel.getVendors(text: viewModel.searchString);
448 }
449 },
450 selectedCountry: viewModel.selectedCountry,
451 ),
452 ),
453 Expanded(
454 child: Observer(builder: (_) {
455 final vendors = viewModel.cakePayVendors;
456
457 if (viewModel.vendorsState is! CakePayVendorLoadedState) {
458 return const _Loading();
459 }
460
461 if (vendors.isEmpty) return Center(child: Text(S.of(context).no_cards_found));
462
463 final loadingMore = viewModel.isLoadingNextPage;
464 final showThumb = vendors.length > 3;
465 final cardsList = Stack(
466 children: [
467 GridView.builder(
468 controller: _scroll,
469 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
470 crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 2 : 1,
471 childAspectRatio: 5,
472 crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5,
473 mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5),
474 padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22),
475 itemCount: vendors.length + (loadingMore ? 1 : 0),
476 itemBuilder: (_, i) {
477 if (i >= vendors.length) return const _Loading();
478 final vendor = vendors[i];
479 return CardItem(
480 logoUrl: vendor.card?.cardImageUrl,
481 title: vendor.name,
482 subTitle: vendor.card?.description ?? '',
483 onTap: () => Navigator.pushNamed(context, Routes.cakePayBuyCardPage,
484 arguments: [vendor]));
485 },
486 ),
487 ],
488 );
489 return showThumb
490 ? Scrollbar(
491 key: ValueKey('cake_pay_shop_tab_scrollbar_key'),
492 thumbVisibility: true,
493 trackVisibility: true,
494 controller: _scroll,
495 child: cardsList,
496 )
497 : cardsList;
498 }),
499 ),
500 ],
501 );
502 }
503 }
504
505 class _Loading extends StatelessWidget {
506 const _Loading();
507
508 @override
509 Widget build(BuildContext context) => Center(
510 child: CircularProgressIndicator(),
511 );
512 }