dev
dart 643 lines 23.4 KB
Raw
1 import 'dart:math';
2 import 'dart:ui';
3
4 import 'package:cake_wallet/entities/qr_scanner.dart';
5 import 'package:cake_wallet/generated/i18n.dart';
6 import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
7 import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
8 import 'package:cake_wallet/new-ui/widgets/scan_page/network_list.dart';
9 import 'package:cake_wallet/new-ui/widgets/send_page/floating_icon_button.dart';
10 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
11 import 'package:cake_wallet/utils/show_pop_up.dart';
12 import 'package:cw_core/utils/print_verbose.dart';
13 import 'package:fast_scanner/fast_scanner.dart';
14 import 'package:flutter/material.dart';
15 import 'package:flutter/scheduler.dart';
16 import 'package:flutter/services.dart';
17 import 'package:ur/ur_decoder.dart';
18
19 class ScanPage extends StatefulWidget {
20 const ScanPage({super.key, this.showHelp = false, this.showManualInput = true});
21
22 final bool showHelp;
23 final bool showManualInput;
24
25 @override
26 State<ScanPage> createState() => _ScanPageState();
27 }
28
29 class _ScanPageState extends State<ScanPage> {
30 final MobileScannerController controller = MobileScannerController();
31 int? _numCameras;
32 bool _frontFlashMode = false;
33 bool _textInputMode = false;
34 final TextEditingController textController = TextEditingController();
35 final FocusNode textFocusNode = FocusNode();
36 List<String> urCodes = [];
37 late var ur = URQRToURQRData(urCodes);
38 final decoder = URDecoder();
39 bool popped = false;
40 Barcode? _barcode;
41
42 @override
43 void initState() {
44 super.initState();
45 controller.addListener(() {
46 if (mounted) {
47 setState(() {
48 _numCameras = controller.value.availableCameras;
49 });
50 }
51 });
52 }
53
54 @override
55 void dispose() {
56 controller.dispose();
57 textController.dispose();
58 textFocusNode.dispose();
59 super.dispose();
60 }
61
62 @override
63 Widget build(BuildContext context) {
64 final double cutoutSize = MediaQuery.of(context).size.width * 0.8;
65 const double cutoutRadius = 24.0;
66 const Duration textModeSwitchDuration = Duration(milliseconds: 300);
67 final buttonColor = _frontFlashMode ? Colors.black.withAlpha(40) : Colors.white.withAlpha(40);
68 final buttonIconColor = _frontFlashMode ? Colors.black : Colors.white;
69 final isScanningURQR = decoder.processedPartsCount() > 0;
70 final double targetRadius = isScanningURQR ? (cutoutSize / 2) : cutoutRadius;
71
72 return Material(
73 child: Stack(
74 children: [
75 MobileScanner(
76 controller: controller,
77 onDetect: _handleBarcode,
78 ),
79 // A full screen tap target would otherwise show up as one giant unlabeled
80 // node; leaving text mode is also possible with the back button.
81 Positioned.fill(
82 child: ExcludeSemantics(
83 child: GestureDetector(
84 onTap: () => setState(() {
85 _textInputMode = false;
86 }),
87 child: RepaintBoundary(
88 child: AnimatedSwitcher(
89 switchInCurve: Curves.easeOutCubic,
90 switchOutCurve: Curves.easeInQuad,
91 duration: textModeSwitchDuration,
92 child: _textInputMode
93 ? BackdropFilter(
94 key: ValueKey(1),
95 filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
96 child: Container(
97 color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
98 ),
99 )
100 : TweenAnimationBuilder<double>(
101 key: const ValueKey(0),
102 tween: Tween<double>(begin: 0.0, end: targetRadius),
103 duration: const Duration(milliseconds: 500),
104 curve: Curves.easeOutCubic,
105 child: BackdropFilter(
106 filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
107 child: Container(
108 color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
109 ),
110 ),
111 builder: (context, radius, child) {
112 return ClipPath(
113 clipper: HoleClipper(
114 width: cutoutSize,
115 height: cutoutSize,
116 radius: radius,
117 ),
118 child: child,
119 );
120 },
121 ),
122 ),
123 ),
124 ),
125 ),
126 ),
127 AnimatedOpacity(
128 opacity: _textInputMode || isScanningURQR ? 0 : 1,
129 duration: textModeSwitchDuration,
130 child: TweenAnimationBuilder<double>(
131 tween: Tween<double>(begin: 0.0, end: targetRadius),
132 duration: const Duration(milliseconds: 500),
133 curve: Curves.easeOutCubic,
134 builder: (context, radius, child) => Center(
135 child: Container(
136 width: cutoutSize,
137 height: cutoutSize,
138 decoration: BoxDecoration(
139 border: Border.all(color: Colors.white, width: 4.0),
140 borderRadius: BorderRadius.circular(radius),
141 ),
142 ),
143 ),
144 ),
145 ),
146 SafeArea(
147 child: Column(
148 children: [
149 ModalTopBar(
150 title: "",
151 trailingWidget: AnimatedOpacity(
152 duration: textModeSwitchDuration,
153 opacity: _textInputMode ? 0 : 1,
154 child: Row(
155 spacing: 8,
156 children: [
157 if ((_numCameras ?? 0) > 1)
158 ModernButton.svg(
159 size: 36,
160 iconSize: 24,
161 svgPath: "assets/new-ui/camera_flip.svg",
162 onPressed: () {
163 controller.switchCamera();
164 setState(() {
165 _frontFlashMode = false;
166 });
167 },
168 iconColor: buttonIconColor,
169 backgroundColor: buttonColor,
170 semanticLabel: S.of(context).switch_camera,
171 ),
172 ModernButton(
173 size: 36,
174 iconSize: 24,
175 icon: Icon(
176 (controller.value.torchState == TorchState.on || _frontFlashMode)
177 ? Icons.flash_off_outlined
178 : Icons.flash_on),
179 semanticLabel:
180 (controller.value.torchState == TorchState.on || _frontFlashMode)
181 ? S.of(context).turn_flash_off
182 : S.of(context).turn_flash_on,
183 onPressed: () {
184 if (controller.value.cameraDirection == CameraFacing.front) {
185 setState(() {
186 _frontFlashMode = !_frontFlashMode;
187 });
188 } else {
189 controller.toggleTorch();
190 }
191 },
192 iconColor: buttonIconColor,
193 backgroundColor: buttonColor,
194 ),
195 ],
196 ),
197 ),
198 leadingWidget: Row(
199 textBaseline: TextBaseline.ideographic,
200 spacing: 24,
201 children: [
202 ModernButton(
203 size: 36,
204 iconSize: 18,
205 icon: Icon(Icons.arrow_back_ios_new),
206 semanticLabel: S.of(context).seed_alert_back,
207 onPressed: () {
208 if (_textInputMode) {
209 setState(() {
210 _textInputMode = false;
211 });
212 } else {
213 Navigator.of(context).pop();
214 }
215 },
216 iconColor: buttonIconColor,
217 backgroundColor: buttonColor,
218 ),
219 Text(
220 S.of(context).scan,
221 style: TextStyle(
222 fontSize: 18, fontWeight: FontWeight.w600, color: buttonIconColor),
223 )
224 ],
225 ),
226 )
227 ],
228 ),
229 ),
230 Positioned(
231 bottom: 18 +
232 max(MediaQuery.of(context).viewInsets.bottom,
233 MediaQuery.of(context).viewPadding.bottom),
234 left: 16,
235 right: 16,
236 child: RepaintBoundary(
237 child: AnimatedOpacity(
238 opacity: _textInputMode ? 1 : 0,
239 duration: textModeSwitchDuration,
240 child: Container(
241 decoration: BoxDecoration(
242 color: Theme.of(context).colorScheme.surfaceContainer,
243 borderRadius: BorderRadius.circular(18)),
244 child: Row(
245 spacing: 10,
246 children: [
247 Expanded(
248 child: MergeSemantics(
249 child: Semantics(
250 label: S.of(context).enter_code,
251 child: TextField(
252 enabled: _textInputMode,
253 controller: textController,
254 focusNode: textFocusNode,
255 onSubmitted: (val) {
256 if (val.isNotEmpty) {
257 Navigator.of(context).pop(val);
258 } else {
259 setState(() {
260 _textInputMode = false;
261 });
262 }
263 },
264 decoration: InputDecoration(hintText: S.of(context).enter_code),
265 ),
266 ),
267 ),
268 ),
269 FloatingIconButton(
270 iconPath: "assets/new-ui/paste.svg",
271 semanticLabel: S.of(context).paste,
272 onPressed: () async {
273 final data = await Clipboard.getData("text/plain");
274 if (data?.text != null) {
275 textController.text = data!.text!;
276 }
277 }),
278 SizedBox(
279 width: 2,
280 )
281 ],
282 ),
283 ),
284 ),
285 ),
286 ),
287 Positioned(
288 bottom: 120,
289 left: 0,
290 right: 0,
291 child: RepaintBoundary(
292 child: AnimatedOpacity(
293 duration: textModeSwitchDuration,
294 opacity: _textInputMode ? 0 : 1,
295 child: Row(
296 mainAxisAlignment: MainAxisAlignment.center,
297 spacing: 8,
298 children: [
299 // "not mvp"
300 // ScanPageButton(
301 // onTap: () async {
302 // FilePickerResult? res = await FilePicker.platform.pickFiles(
303 // type: FileType.image,
304 // allowMultiple: false,
305 // withData: false,
306 // );
307 //
308 // if (res != null && res.paths.isNotEmpty && res.paths.first != null) {
309 // final capture = await controller.analyzeImage(res.paths.first!);
310 // if (capture != null) {
311 // _handleBarcode(capture);
312 // }
313 // }
314 // },
315 // icon: Icons.photo_outlined,
316 // label: S.of(context).gallery,
317 // buttonColor: buttonColor,
318 // buttonIconColor: buttonIconColor),
319 if (widget.showManualInput)
320 ScanPageButton(
321 onTap: () {
322 setState(() {
323 _textInputMode = true;
324 });
325 Future.delayed(textModeSwitchDuration)
326 .then((val) => textFocusNode.requestFocus());
327 },
328 icon: Icons.edit_outlined,
329 label: S.of(context).manual_input,
330 buttonColor: buttonColor,
331 buttonIconColor: buttonIconColor),
332 if (widget.showHelp)
333 ScanPageButton(
334 onTap: () async {
335 if (_textInputMode) return;
336 try {
337 controller.stop();
338 await showModalBottomSheet(
339 context: context,
340 isScrollControlled: true,
341 useSafeArea: true,
342 backgroundColor: Theme.of(context).colorScheme.surface,
343 builder: (context) => ScanPageNetworkList());
344 } finally {
345 controller.start();
346 }
347 },
348 icon: Icons.question_mark,
349 semanticsLabel: S.of(context).help,
350 buttonColor: buttonColor,
351 buttonIconColor: buttonIconColor)
352 ],
353 ),
354 ),
355 )),
356 AnimatedOpacity(
357 duration: Duration(milliseconds: 500),
358 opacity: isScanningURQR ? 1 : 0,
359 child: Center(
360 child: SegmentedCircularProgress(
361 progress: URQrProgress(
362 expectedPartCount: decoder.expectedPartCount() ?? 0,
363 processedPartsCount: decoder.processedPartsCount(),
364 receivedPartIndexes: decoder.receivedPartIndexes().toList(),
365 percentage: decoder.estimatedPercentComplete(),
366 ),
367 size: cutoutSize + 20,
368 activeColor: Theme.of(context).colorScheme.primary,
369 inactiveColor: Colors.white.withAlpha(102)),
370 ),
371 ),
372 Positioned(
373 bottom: 120,
374 left: 0,
375 right: 0,
376 child: IgnorePointer(
377 child: AnimatedOpacity(
378 duration: Duration(milliseconds: 500),
379 opacity: isScanningURQR ? 1 : 0,
380 // Three separate digits mean nothing on their own: announce the progress
381 // as one node, and only while a multi part code is actually being scanned.
382 child: ExcludeSemantics(
383 excluding: !isScanningURQR,
384 child: Semantics(
385 container: true,
386 liveRegion: true,
387 label: S.of(context).qr_parts_scanned(
388 "${decoder.processedPartsCount()}", "${decoder.expectedPartCount() ?? 0}"),
389 excludeSemantics: true,
390 child: Row(
391 mainAxisAlignment: MainAxisAlignment.center,
392 children: [
393 Text(
394 "${decoder.processedPartsCount()}",
395 style: TextStyle(
396 fontSize: 45,
397 fontWeight: FontWeight.w500,
398 color: Theme.of(context).colorScheme.primary),
399 ),
400 Text(
401 "/",
402 style: TextStyle(
403 fontSize: 45, color: Theme.of(context).colorScheme.onSurfaceVariant),
404 ),
405 Text(
406 "${decoder.expectedPartCount()}",
407 style: TextStyle(
408 fontSize: 45, color: Theme.of(context).colorScheme.onSurface),
409 ),
410 ],
411 ),
412 ),
413 ),
414 ),
415 ),
416 )
417 ],
418 ),
419 );
420 }
421
422 void _handleBarcode(BarcodeCapture barcodes) {
423 try {
424 _handleBarcodeInternal(barcodes);
425 } catch (e, st) {
426 showPopUp<void>(
427 context: context,
428 builder: (context) {
429 return AlertWithOneAction(
430 alertTitle: S.of(context).error,
431 alertContent: S.of(context).error_dialog_content,
432 buttonText: S.of(context).ok,
433 buttonAction: () {
434 Navigator.of(context).pop();
435 },
436 );
437 },
438 );
439 printV("$e\n$st");
440 }
441 }
442
443 void _handleBarcodeInternal(BarcodeCapture barcodes) {
444 for (final barcode in barcodes.barcodes) {
445 if (barcode.rawValue?.trim().isEmpty ?? false == false) continue;
446 if (barcode.rawValue!.startsWith("ur:")) {
447 if (urCodes.contains(barcode.rawValue)) continue;
448 decoder.receivePart(barcode.rawValue!);
449 setState(() {
450 urCodes.add(barcode.rawValue!);
451 ur = URQRToURQRData(urCodes);
452 });
453 if (decoder.estimatedPercentComplete() == 1) {
454 setState(() {
455 popped = true;
456 });
457 SchedulerBinding.instance.addPostFrameCallback((_) {
458 Navigator.of(context).pop(ur.inputs.join("\n"));
459 });
460 }
461 ;
462 }
463 }
464 if (urCodes.isNotEmpty) return;
465 if (mounted) {
466 setState(() {
467 _barcode = barcodes.barcodes.firstOrNull;
468 });
469 if (_barcode != null && popped != true) {
470 setState(() {
471 popped = true;
472 });
473 Navigator.of(context).pop(_barcode!.rawValue ?? _barcode!.rawBytes);
474 }
475 }
476 }
477 }
478
479 class ScanPageButton extends StatelessWidget {
480 const ScanPageButton(
481 {super.key,
482 required this.onTap,
483 required this.icon,
484 this.label,
485 this.semanticsLabel,
486 required this.buttonColor,
487 required this.buttonIconColor});
488
489 final VoidCallback onTap;
490 final IconData icon;
491 final String? label;
492
493 /// Name for the icon-only variant, which has no visible [label].
494 final String? semanticsLabel;
495 final Color buttonColor;
496 final Color buttonIconColor;
497
498 @override
499 Widget build(BuildContext context) {
500 return Semantics(
501 button: true,
502 enabled: true,
503 label: semanticsLabel ?? label,
504 onTap: onTap,
505 excludeSemantics: true,
506 child: GestureDetector(
507 onTap: onTap,
508 behavior: HitTestBehavior.opaque,
509 child: Container(
510 decoration:
511 BoxDecoration(color: buttonColor, borderRadius: BorderRadius.circular(99999)),
512 child: Padding(
513 padding: EdgeInsets.only(
514 top: 10,
515 bottom: 10,
516 left: label == null ? 10 : 16,
517 right: label == null ? 10 : 20),
518 child: Row(
519 spacing: 10,
520 children: [
521 Icon(icon, size: 28, color: buttonIconColor),
522 if (label != null)
523 Text(label!,
524 style: TextStyle(
525 fontSize: 16, fontWeight: FontWeight.w500, color: buttonIconColor))
526 ],
527 ),
528 ),
529 )));
530 }
531 }
532
533 class HoleClipper extends CustomClipper<Path> {
534 final double width;
535 final double height;
536 final double radius;
537
538 HoleClipper({
539 required this.width,
540 required this.height,
541 required this.radius,
542 });
543
544 @override
545 Path getClip(Size size) {
546 final Path fullScreenPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
547
548 final Path cutoutPath = Path()
549 ..addRRect(
550 RRect.fromRectAndRadius(
551 Rect.fromCenter(
552 center: Offset(size.width / 2, size.height / 2),
553 width: width,
554 height: height,
555 ),
556 Radius.circular(radius),
557 ),
558 );
559
560 return Path.combine(
561 PathOperation.difference,
562 fullScreenPath,
563 cutoutPath,
564 );
565 }
566
567 @override
568 bool shouldReclip(covariant HoleClipper oldClipper) {
569 return oldClipper.width != width || oldClipper.height != height || oldClipper.radius != radius;
570 }
571 }
572
573 class SegmentedCircularProgress extends StatelessWidget {
574 final URQrProgress progress;
575 final Color activeColor;
576 final Color inactiveColor;
577 final double size;
578
579 const SegmentedCircularProgress({
580 Key? key,
581 required this.progress,
582 required this.activeColor,
583 required this.inactiveColor,
584 required this.size,
585 }) : super(key: key);
586
587 @override
588 Widget build(BuildContext context) {
589 return CustomPaint(
590 size: Size(size, size),
591 painter: _SegmentedCirclePainter(
592 progress: progress,
593 activeColor: activeColor,
594 inactiveColor: inactiveColor,
595 ),
596 );
597 }
598 }
599
600 class _SegmentedCirclePainter extends CustomPainter {
601 final URQrProgress progress;
602 static const double strokeWidth = 10;
603 static const double gapAngle = 0.08;
604 final Color activeColor;
605 final Color inactiveColor;
606
607 _SegmentedCirclePainter({
608 required this.progress,
609 required this.activeColor,
610 required this.inactiveColor,
611 });
612
613 @override
614 void paint(Canvas canvas, Size size) {
615 final rect = Rect.fromLTWH(0, 0, size.width, size.height);
616 final totalSegments = progress.expectedPartCount;
617
618 final sweepAngle = (2 * pi - (gapAngle * totalSegments)) / totalSegments;
619
620 final paint = Paint()
621 ..style = PaintingStyle.stroke
622 ..strokeWidth = strokeWidth
623 ..strokeCap = StrokeCap.butt;
624
625 double startAngle = -pi / 2 + (gapAngle / 2);
626
627 for (int i = 0; i < totalSegments; i++) {
628 bool isHighlighted = progress.receivedPartIndexes.contains(i);
629
630 paint.color = isHighlighted ? activeColor : inactiveColor;
631
632 canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
633
634 startAngle += sweepAngle + gapAngle;
635 }
636 }
637
638 @override
639 bool shouldRepaint(covariant _SegmentedCirclePainter oldDelegate) {
640 return !oldDelegate.progress.equals(progress) ||
641 oldDelegate.progress.expectedPartCount != progress.expectedPartCount;
642 }
643 }