dev
dart 479 lines 17.1 KB
Raw
1 import 'package:cake_wallet/exchange/trade.dart';
2 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
3 import 'package:cake_wallet/utils/address_formatter.dart';
4 import 'package:cake_wallet/utils/show_bar.dart';
5 import 'package:flutter/material.dart';
6 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
7 import 'package:cw_core/wallet_type.dart';
8 import 'package:cake_wallet/view_model/exchange/exchange_trade_view_model.dart';
9 import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart';
10 import 'package:cake_wallet/core/execution_state.dart';
11 import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
12 import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
13 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
14 import 'package:cake_wallet/utils/show_pop_up.dart';
15 import 'package:cake_wallet/generated/i18n.dart';
16 import 'package:cw_core/utils/print_verbose.dart';
17 import 'package:flutter/services.dart';
18 import 'package:mobx/mobx.dart';
19 import 'package:flutter_mobx/flutter_mobx.dart';
20
21 class SwapDetailsBottomSheet extends StatefulWidget {
22 SwapDetailsBottomSheet({
23 Key? key,
24 required this.exchangeTradeViewModel,
25 }) : super(key: key);
26
27 final ExchangeTradeViewModel exchangeTradeViewModel;
28
29 @override
30 State<SwapDetailsBottomSheet> createState() => _SwapDetailsBottomSheetState();
31 }
32
33 class _SwapDetailsBottomSheetState extends State<SwapDetailsBottomSheet> {
34 bool _effectsInstalled = false;
35 ReactionDisposer? _exchangeStateReaction;
36 BuildContext? _loadingBottomSheetContext;
37 bool _showingFailureDialog = false;
38
39 @override
40 void initState() {
41 super.initState();
42 WidgetsBinding.instance.addPostFrameCallback((_) {
43 if (mounted) _setEffects();
44 });
45 }
46
47 @override
48 void dispose() {
49 widget.exchangeTradeViewModel.timer?.cancel();
50 _exchangeStateReaction?.reaction.dispose();
51 super.dispose();
52 }
53
54 void _setEffects() {
55 if (_effectsInstalled) {
56 printV('Swap details bottom sheet effects already installed');
57 return;
58 }
59
60 final initialState = widget.exchangeTradeViewModel.sendViewModel.state;
61
62 if (initialState is FailureState && !_showingFailureDialog) {
63 _showingFailureDialog = true;
64 printV('Initial failure state: $initialState');
65 WidgetsBinding.instance.addPostFrameCallback((_) {
66 if (mounted && context.mounted) {
67 showPopUp<void>(
68 context: context,
69 builder: (BuildContext popupContext) {
70 return AlertWithOneAction(
71 key: ValueKey('swap_details_send_failure_dialog_key'),
72 buttonKey: ValueKey('swap_details_send_failure_dialog_button_key'),
73 alertTitle: S.of(popupContext).error,
74 alertContent: initialState.error,
75 buttonText: S.of(popupContext).ok,
76 buttonAction: () {
77 _showingFailureDialog = false;
78 Navigator.of(popupContext).pop();
79 },
80 );
81 },
82 );
83 } else {
84 _showingFailureDialog = false;
85 }
86 });
87 }
88
89 _exchangeStateReaction = reaction(
90 (_) => widget.exchangeTradeViewModel.sendViewModel.state,
91 (ExecutionState state) async {
92 if (state is! IsExecutingState &&
93 state is! TransactionCommitting &&
94 _loadingBottomSheetContext != null &&
95 _loadingBottomSheetContext!.mounted) {
96 Navigator.of(_loadingBottomSheetContext!).pop();
97 }
98
99 if (state is FailureState && !_showingFailureDialog) {
100 _showingFailureDialog = true;
101 WidgetsBinding.instance.addPostFrameCallback((_) {
102 if (mounted && context.mounted) {
103 showPopUp<void>(
104 context: context,
105 builder: (BuildContext popupContext) {
106 return AlertWithOneAction(
107 key: ValueKey('swap_details_send_failure_dialog_key'),
108 buttonKey: ValueKey('swap_details_send_failure_dialog_button_key'),
109 alertTitle: S.of(popupContext).error,
110 alertContent: state.error,
111 buttonText: S.of(popupContext).ok,
112 buttonAction: () {
113 _showingFailureDialog = false;
114 Navigator.of(popupContext).pop();
115 if (mounted) {
116 Navigator.of(context, rootNavigator: true).pop();
117 }
118 },
119 );
120 },
121 );
122 } else {
123 _showingFailureDialog = false;
124 }
125 });
126 }
127
128 if (state is IsExecutingState) {
129 // Wait a bit to avoid showing the loading dialog if transaction is failed
130 await Future.delayed(const Duration(milliseconds: 300));
131 final currentState = widget.exchangeTradeViewModel.sendViewModel.state;
132 if (currentState is ExecutedSuccessfullyState || currentState is FailureState) {
133 return;
134 }
135
136 WidgetsBinding.instance.addPostFrameCallback((_) {
137 if (context.mounted) {
138 showModalBottomSheet<void>(
139 context: context,
140 isDismissible: false,
141 builder: (BuildContext context) {
142 _loadingBottomSheetContext = context;
143 return LoadingBottomSheet(
144 titleText: S.of(context).generating_transaction,
145 );
146 },
147 );
148 }
149 });
150 }
151
152 if (state is ExecutedSuccessfullyState) {
153 WidgetsBinding.instance.addPostFrameCallback((_) async {
154 if (context.mounted) {
155 await widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context);
156 }
157 });
158 }
159
160 if (state is TransactionCommitted) {
161 WidgetsBinding.instance.addPostFrameCallback(
162 (_) async {
163 if (!mounted) return;
164
165 await showModalBottomSheet<void>(
166 context: context,
167 isScrollControlled: true,
168 builder: (BuildContext bottomSheetContext) {
169 return InfoBottomSheet(
170 footerType: FooterType.singleActionButton,
171 titleText: S.of(bottomSheetContext).transaction_sent,
172 contentImage: 'assets/images/birthday_cake.png',
173 singleActionButtonText: S.of(bottomSheetContext).close,
174 singleActionButtonKey: ValueKey('swap_details_sent_dialog_ok_button_key'),
175 onSingleActionButtonPressed: () {
176 Navigator.of(bottomSheetContext).pop();
177 if (mounted) {
178 Navigator.of(context, rootNavigator: true).pop();
179 }
180 ;
181 },
182 );
183 },
184 );
185 },
186 );
187 }
188 },
189 );
190
191 _effectsInstalled = true;
192 }
193
194 @override
195 Widget build(BuildContext context) {
196 return _SwapDetailsBottomSheetContent(
197 titleText: 'Confirm Swap',
198 footerType: FooterType.none,
199 maxHeight: 900,
200 exchangeTradeViewModel: widget.exchangeTradeViewModel,
201 onExecuteSwap: _executeSwap,
202 );
203 }
204
205 void _executeSwap() async {
206 if (!widget.exchangeTradeViewModel.isSendable) return;
207
208 try {
209 await widget.exchangeTradeViewModel.confirmSending();
210 } catch (e) {
211 printV('Error executing swap: $e');
212 if (mounted) {
213 showPopUp<void>(
214 context: context,
215 builder: (BuildContext popupContext) {
216 return AlertWithOneAction(
217 key: ValueKey('swap_details_execution_error_dialog_key'),
218 buttonKey: ValueKey('swap_details_execution_error_dialog_button_key'),
219 alertTitle: S.of(popupContext).error,
220 alertContent: e.toString(),
221 buttonText: S.of(popupContext).ok,
222 buttonAction: () => Navigator.of(popupContext).pop(),
223 );
224 },
225 );
226 }
227 }
228 }
229 }
230
231 class _SwapDetailsBottomSheetContent extends BaseBottomSheet {
232 _SwapDetailsBottomSheetContent({
233 required String titleText,
234 required FooterType footerType,
235 required double maxHeight,
236 required this.exchangeTradeViewModel,
237 required this.onExecuteSwap,
238 }) : super(
239 titleText: titleText,
240 footerType: footerType,
241 maxHeight: maxHeight,
242 );
243
244 final ExchangeTradeViewModel exchangeTradeViewModel;
245 final VoidCallback onExecuteSwap;
246
247 @override
248 Widget contentWidget(BuildContext context) {
249 return Column(
250 children: [
251 _SwapDetailsContent(
252 trade: exchangeTradeViewModel.trade,
253 exchangeTradeViewModel: exchangeTradeViewModel,
254 ),
255 Padding(
256 padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
257 child: Observer(
258 builder: (_) {
259 final sendingState = exchangeTradeViewModel.sendViewModel.state;
260 final isDisabled = !exchangeTradeViewModel.isSendable ||
261 exchangeTradeViewModel.trade.inputAddress == null ||
262 exchangeTradeViewModel.trade.inputAddress!.isEmpty;
263
264 if (isDisabled || sendingState is IsExecutingState) {
265 return Container(
266 width: double.infinity,
267 height: 48,
268 decoration: BoxDecoration(
269 borderRadius: BorderRadius.circular(10),
270 color: Theme.of(context).colorScheme.surfaceContainer,
271 ),
272 child: Center(
273 child: sendingState is IsExecutingState
274 ? CircularProgressIndicator()
275 : Text(
276 'Swipe to swap',
277 style: Theme.of(context).textTheme.bodyMedium!.copyWith(
278 fontSize: 16,
279 fontWeight: FontWeight.w600,
280 color: Theme.of(context).colorScheme.onSurfaceVariant,
281 ),
282 ),
283 ),
284 );
285 }
286
287 return StandardSlideButton(
288 tileBackgroundColor: Theme.of(context).colorScheme.surfaceContainer,
289 knobColor: Theme.of(context).colorScheme.primary,
290 buttonText: 'Swipe to swap',
291 onSlideComplete: onExecuteSwap,
292 accessibleNavigationModeButtonText: 'Complete swap',
293 );
294 },
295 ),
296 ),
297 const SizedBox(height: 32),
298 ],
299 );
300 }
301 }
302
303 class _SwapDetailsContent extends StatelessWidget {
304 const _SwapDetailsContent({required this.trade, required this.exchangeTradeViewModel});
305
306 final Trade trade;
307 final ExchangeTradeViewModel exchangeTradeViewModel;
308
309 @override
310 Widget build(BuildContext context) {
311 return Container(
312 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
313 child: Observer(
314 builder: (_) {
315 return Column(
316 mainAxisSize: MainAxisSize.min,
317 crossAxisAlignment: CrossAxisAlignment.center,
318 children: [
319 _SwapDetailsTile(
320 label: 'You Send',
321 value: '${trade.amount} ${trade.from?.title ?? ''}',
322 valueFiatFormatted: exchangeTradeViewModel.sendAmountFiatFormatted,
323 ),
324 const SizedBox(height: 8),
325 _SwapDetailsTile(
326 label: 'You Get',
327 value: '${trade.receiveAmount ?? '0'} ${trade.to?.title ?? ''}',
328 valueFiatFormatted: exchangeTradeViewModel
329 .getReceiveAmountFiatFormatted(trade.receiveAmount ?? '0.0'),
330 ),
331 const SizedBox(height: 8),
332 Container(
333 width: double.infinity,
334 padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
335 decoration: BoxDecoration(
336 color: Theme.of(context).colorScheme.surfaceContainer,
337 borderRadius: BorderRadius.circular(10),
338 ),
339 child: Column(
340 crossAxisAlignment: CrossAxisAlignment.start,
341 children: [
342 Text(
343 'To this Address',
344 style: Theme.of(context)
345 .textTheme
346 .bodyMedium!
347 .copyWith(fontWeight: FontWeight.w500, fontSize: 16),
348 ),
349 const SizedBox(height: 4),
350 AddressFormatter.buildSegmentedAddress(
351 address: trade.payoutAddress ?? '',
352 walletType:
353 trade.to != null ? cryptoCurrencyOrTokenToWalletType(trade.to!) : null,
354 evenTextStyle: Theme.of(context)
355 .textTheme
356 .bodyMedium!
357 .copyWith(fontWeight: FontWeight.w600, fontSize: 14),
358 ),
359 ],
360 ),
361 ),
362 const SizedBox(height: 8),
363 Container(
364 width: double.infinity,
365 padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
366 decoration: BoxDecoration(
367 color: Theme.of(context).colorScheme.surfaceContainer,
368 borderRadius: BorderRadius.circular(12),
369 ),
370 child: Row(
371 mainAxisAlignment: MainAxisAlignment.spaceBetween,
372 children: [
373 Row(
374 children: [
375 CakeImageWidget(
376 imageUrl: trade.provider.image,
377 width: 36,
378 height: 36,
379 ),
380 const SizedBox(width: 8),
381 Text(
382 trade.provider.title,
383 style: Theme.of(context).textTheme.bodyMedium!.copyWith(
384 fontWeight: FontWeight.w500,
385 fontSize: 16,
386 ),
387 ),
388 const SizedBox(width: 8),
389 ],
390 ),
391 Expanded(
392 child: GestureDetector(
393 onTap: () {
394 Clipboard.setData(ClipboardData(text: trade.id));
395 showBar<void>(context, S.of(context).copied_to_clipboard);
396 },
397 child: Row(
398 mainAxisAlignment: MainAxisAlignment.end,
399 children: [
400 Text(
401 'ID: ',
402 style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 18),
403 ),
404 Flexible(
405 child: Text(
406 trade.id,
407 style:
408 Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 18),
409 ),
410 ),
411 ],
412 ),
413 ),
414 ),
415 ],
416 ),
417 ),
418 ],
419 );
420 },
421 ),
422 );
423 }
424 }
425
426 class _SwapDetailsTile extends StatelessWidget {
427 const _SwapDetailsTile({
428 required this.label,
429 required this.value,
430 required this.valueFiatFormatted,
431 });
432
433 final String label;
434 final String value;
435 final String valueFiatFormatted;
436 @override
437 Widget build(BuildContext context) {
438 return Container(
439 padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
440 decoration: BoxDecoration(
441 color: Theme.of(context).colorScheme.surfaceContainer,
442 borderRadius: BorderRadius.circular(12),
443 ),
444 child: Row(
445 mainAxisAlignment: MainAxisAlignment.spaceBetween,
446 children: [
447 Text(
448 label,
449 style: Theme.of(context)
450 .textTheme
451 .bodyMedium!
452 .copyWith(fontWeight: FontWeight.w500, fontSize: 16),
453 ),
454 Column(
455 crossAxisAlignment: CrossAxisAlignment.end,
456 children: [
457 Text(
458 value,
459 style: Theme.of(context).textTheme.bodyMedium!.copyWith(
460 fontWeight: FontWeight.w600,
461 fontSize: 18,
462 ),
463 ),
464 if (valueFiatFormatted.isNotEmpty)
465 Text(
466 valueFiatFormatted,
467 style: Theme.of(context).textTheme.bodyMedium!.copyWith(
468 fontSize: 10,
469 fontWeight: FontWeight.w600,
470 color: Theme.of(context).colorScheme.onSurfaceVariant,
471 ),
472 ),
473 ],
474 ),
475 ],
476 ),
477 );
478 }
479 }