improved scan screen [cw-1507] (#3281)

* (wip) new scan screen

* improved scan screen

* fixes for ui

* switch to modal sheet

* add urqr (cupcake) progress indicator

* fixes

* Update lib/new-ui/pages/scan_page.dart

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
malik1004x
2026-06-03 18:13:22 +02:00
committed by GitHub
co-authored by Omar Hatem
parent 52c380a455
commit 86ea17d5bf
17 changed files with 789 additions and 27 deletions
@@ -19,6 +19,7 @@ class ListItemRegularRow extends ListItem {
this.copyableText,
this.leadingIconErrorWidget,
this.leadingIconSize,
this.iconColor,
});
final String? subtitle;
@@ -35,4 +36,5 @@ class ListItemRegularRow extends ListItem {
final double? trailingIconSize;
final Widget? leadingIconErrorWidget;
final double? leadingIconSize;
final Color? iconColor;
}
+21 -8
View File
@@ -1,26 +1,39 @@
import 'dart:math';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/pages/scan_page.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:fast_scanner/fast_scanner.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:ur/ur_decoder.dart';
var isQrScannerShown = false;
Future<String?> presentQRScanner(BuildContext context) async {
Future<String?> presentQRScanner(BuildContext context, {bool showHelp = false, bool showManualInput = true, bool useModal = false}) async {
isQrScannerShown = true;
try {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) {
return BarcodeScannerSimple();
},
),
);
final result = useModal
? await CupertinoScaffold.showCupertinoModalBottomSheet<String?>(
context: context,
builder: (context) => ScanPage(
showHelp: showHelp,
showManualInput: showManualInput,
))
: await Navigator.of(context).push<String>(
CupertinoPageRoute(
builder: (context) {
return ScanPage(
showHelp: showHelp,
showManualInput: showManualInput,
);
},
),
);
isQrScannerShown = false;
return result;
} catch (e) {
+588 -4
View File
@@ -1,10 +1,594 @@
import 'package:flutter/material.dart';
import 'dart:math';
import 'dart:ui';
class ScanPage extends StatelessWidget {
const ScanPage({super.key});
import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/new-ui/widgets/scan_page/network_list.dart';
import 'package:cake_wallet/new-ui/widgets/send_page/floating_icon_button.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:fast_scanner/fast_scanner.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:ur/ur_decoder.dart';
class ScanPage extends StatefulWidget {
const ScanPage({super.key, this.showHelp = false, this.showManualInput = true});
final bool showHelp;
final bool showManualInput;
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final MobileScannerController controller = MobileScannerController();
int? _numCameras;
bool _frontFlashMode = false;
bool _textInputMode = false;
final TextEditingController textController = TextEditingController();
final FocusNode textFocusNode = FocusNode();
List<String> urCodes = [];
late var ur = URQRToURQRData(urCodes);
final decoder = URDecoder();
bool popped = false;
Barcode? _barcode;
@override
void initState() {
super.initState();
controller.addListener(() => if (mounted) setState(() {
_numCameras = controller.value.availableCameras;
}));
}
@override
void dispose() {
controller.dispose();
textController.dispose();
textFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
final double cutoutSize = MediaQuery.of(context).size.width * 0.8;
const double cutoutRadius = 24.0;
const Duration textModeSwitchDuration = Duration(milliseconds: 300);
final buttonColor = _frontFlashMode ? Colors.black.withAlpha(40) : Colors.white.withAlpha(40);
final buttonIconColor = _frontFlashMode ? Colors.black : Colors.white;
final isScanningURQR = decoder.processedPartsCount() > 0;
final double targetRadius = isScanningURQR ? (cutoutSize / 2) : cutoutRadius;
return Material(
child: Stack(
children: [
MobileScanner(
controller: controller,
onDetect: _handleBarcode,
),
Positioned.fill(
child: GestureDetector(
onTap: () => setState(() {
_textInputMode = false;
}),
child: RepaintBoundary(
child: AnimatedSwitcher(
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInQuad,
duration: textModeSwitchDuration,
child: _textInputMode
? BackdropFilter(
key: ValueKey(1),
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
),
)
: TweenAnimationBuilder<double>(
key: const ValueKey(0),
tween: Tween<double>(begin: 0.0, end: targetRadius),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: _frontFlashMode ? Colors.white : Colors.black.withAlpha(153),
),
),
builder: (context, radius, child) {
return ClipPath(
clipper: HoleClipper(
width: cutoutSize,
height: cutoutSize,
radius: radius,
),
child: child,
);
},
),
),
),
),
),
AnimatedOpacity(
opacity: _textInputMode || isScanningURQR ? 0 : 1,
duration: textModeSwitchDuration,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: targetRadius),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
builder: (context, radius, child) => Center(
child: Container(
width: cutoutSize,
height: cutoutSize,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 4.0),
borderRadius: BorderRadius.circular(radius),
),
),
),
),
),
SafeArea(
child: Column(
children: [
ModalTopBar(
title: "",
trailingWidget: AnimatedOpacity(
duration: textModeSwitchDuration,
opacity: _textInputMode ? 0 : 1,
child: Row(
spacing: 8,
children: [
if ((_numCameras ?? 0) > 1)
ModernButton.svg(
size: 36,
iconSize: 24,
svgPath: "assets/new-ui/camera_flip.svg",
onPressed: () {
controller.switchCamera();
setState(() {
_frontFlashMode = false;
});
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
),
ModernButton(
size: 36,
iconSize: 24,
icon: Icon(
(controller.value.torchState == TorchState.on || _frontFlashMode)
? Icons.flash_off_outlined
: Icons.flash_on),
onPressed: () {
if (controller.value.cameraDirection == CameraFacing.front) {
setState(() {
_frontFlashMode = !_frontFlashMode;
});
} else {
controller.toggleTorch();
}
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
),
],
),
),
leadingWidget: Row(
textBaseline: TextBaseline.ideographic,
spacing: 24,
children: [
ModernButton(
size: 36,
iconSize: 18,
icon: Icon(Icons.arrow_back_ios_new),
onPressed: () {
if (_textInputMode) {
setState(() {
_textInputMode = false;
});
} else {
Navigator.of(context).pop();
}
},
iconColor: buttonIconColor,
backgroundColor: buttonColor,
),
Text(
S.of(context).scan,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w600, color: buttonIconColor),
)
],
),
)
],
),
),
Positioned(
bottom: 18 +
max(MediaQuery.of(context).viewInsets.bottom,
MediaQuery.of(context).viewPadding.bottom),
left: 16,
right: 16,
child: RepaintBoundary(
child: AnimatedOpacity(
opacity: _textInputMode ? 1 : 0,
duration: textModeSwitchDuration,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18)),
child: Row(
spacing: 10,
children: [
Expanded(
child: TextField(
enabled: _textInputMode,
controller: textController,
focusNode: textFocusNode,
onSubmitted: (val) {
if (val.isNotEmpty) {
Navigator.of(context).pop(val);
} else {
setState(() {
_textInputMode = false;
});
}
},
decoration: InputDecoration(hintText: S.of(context).enter_code),
),
),
FloatingIconButton(
iconPath: "assets/new-ui/paste.svg",
onPressed: () async {
final data = await Clipboard.getData("text/plain");
if (data?.text != null) {
textController.text = data!.text!;
}
}),
SizedBox(
width: 2,
)
],
),
),
),
),
),
Positioned(
bottom: 120,
left: 0,
right: 0,
child: RepaintBoundary(
child: AnimatedOpacity(
duration: textModeSwitchDuration,
opacity: _textInputMode ? 0 : 1,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
// "not mvp"
// ScanPageButton(
// onTap: () async {
// FilePickerResult? res = await FilePicker.platform.pickFiles(
// type: FileType.image,
// allowMultiple: false,
// withData: false,
// );
//
// if (res != null && res.paths.isNotEmpty && res.paths.first != null) {
// final capture = await controller.analyzeImage(res.paths.first!);
// if (capture != null) {
// _handleBarcode(capture);
// }
// }
// },
// icon: Icons.photo_outlined,
// label: S.of(context).gallery,
// buttonColor: buttonColor,
// buttonIconColor: buttonIconColor),
if (widget.showManualInput)
ScanPageButton(
onTap: () {
setState(() {
_textInputMode = true;
});
Future.delayed(textModeSwitchDuration)
.then((val) => textFocusNode.requestFocus());
},
icon: Icons.edit_outlined,
label: S.of(context).manual_input,
buttonColor: buttonColor,
buttonIconColor: buttonIconColor),
if (widget.showHelp)
ScanPageButton(
onTap: () async {
if(_textInputMode) return;
try {
controller.stop();
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (context) => ScanPageNetworkList());
} finally {
controller.start();
}
},
icon: Icons.question_mark,
buttonColor: buttonColor,
buttonIconColor: buttonIconColor)
],
),
),
)),
AnimatedOpacity(
duration: Duration(milliseconds: 500),
opacity: isScanningURQR ? 1 : 0,
child: Center(
child: SegmentedCircularProgress(
progress: URQrProgress(
expectedPartCount: decoder.expectedPartCount() ?? 0,
processedPartsCount: decoder.processedPartsCount(),
receivedPartIndexes: decoder.receivedPartIndexes().toList(),
percentage: decoder.estimatedPercentComplete(),
),
size: cutoutSize + 20,
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
Positioned(
bottom: 120,
left: 0,
right: 0,
child: AnimatedOpacity(
duration: Duration(milliseconds: 500),
opacity: isScanningURQR ? 1 : 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${decoder.processedPartsCount()}",
style: TextStyle(
fontSize: 45,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary),
),
Text(
"/",
style: TextStyle(
fontSize: 45, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(
"${decoder.expectedPartCount()}",
style: TextStyle(fontSize: 45, color: Theme.of(context).colorScheme.onSurface),
),
],
),
),
)
],
),
);
}
void _handleBarcode(BarcodeCapture barcodes) {
try {
_handleBarcodeInternal(barcodes);
} catch (e, st) {
showPopUp<void>(
context: context,
builder: (context) {
return AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: S.of(context).error_dialog_content,
buttonText: 'ok',
buttonAction: () {
Navigator.of(context).pop();
},
);
},
);
printV("$e\n$st");
}
}
void _handleBarcodeInternal(BarcodeCapture barcodes) {
for (final barcode in barcodes.barcodes) {
if (barcode.rawValue?.trim().isEmpty ?? false == false) continue;
if (barcode.rawValue!.startsWith("ur:")) {
if (urCodes.contains(barcode.rawValue)) continue;
decoder.receivePart(barcode.rawValue!);
setState(() {
urCodes.add(barcode.rawValue!);
ur = URQRToURQRData(urCodes);
});
if (decoder.estimatedPercentComplete() == 1) {
setState(() {
popped = true;
});
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pop(ur.inputs.join("\n"));
});
}
;
}
}
if (urCodes.isNotEmpty) return;
if (mounted) {
setState(() {
_barcode = barcodes.barcodes.firstOrNull;
});
if (_barcode != null && popped != true) {
setState(() {
popped = true;
});
Navigator.of(context).pop(_barcode!.rawValue ?? _barcode!.rawBytes);
}
}
}
}
class ScanPageButton extends StatelessWidget {
const ScanPageButton(
{super.key,
required this.onTap,
required this.icon,
this.label,
required this.buttonColor,
required this.buttonIconColor});
final VoidCallback onTap;
final IconData icon;
final String? label;
final Color buttonColor;
final Color buttonIconColor;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(color: buttonColor, borderRadius: BorderRadius.circular(99999)),
child: Padding(
padding: EdgeInsets.only(
top: 10, bottom: 10, left: label == null ? 10 : 16, right: label == null ? 10 : 20),
child: Row(
spacing: 10,
children: [
Icon(icon, size: 28, color: buttonIconColor),
if (label != null)
Text(label!,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w500, color: buttonIconColor))
],
),
),
));
}
}
class HoleClipper extends CustomClipper<Path> {
final double width;
final double height;
final double radius;
HoleClipper({
required this.width,
required this.height,
required this.radius,
});
@override
Path getClip(Size size) {
final Path fullScreenPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
final Path cutoutPath = Path()
..addRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: width,
height: height,
),
Radius.circular(radius),
),
);
return Path.combine(
PathOperation.difference,
fullScreenPath,
cutoutPath,
);
}
@override
bool shouldReclip(covariant HoleClipper oldClipper) {
return oldClipper.width != width || oldClipper.height != height || oldClipper.radius != radius;
}
}
class SegmentedCircularProgress extends StatelessWidget {
final URQrProgress progress;
final Color activeColor;
final Color inactiveColor;
final double size;
const SegmentedCircularProgress({
Key? key,
required this.progress,
required this.activeColor,
required this.inactiveColor,
required this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _SegmentedCirclePainter(
progress: progress,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
);
}
}
class _SegmentedCirclePainter extends CustomPainter {
final URQrProgress progress;
static const double strokeWidth = 10;
static const double gapAngle = 0.08;
final Color activeColor;
final Color inactiveColor;
_SegmentedCirclePainter({
required this.progress,
required this.activeColor,
required this.inactiveColor,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
final totalSegments = progress.expectedPartCount;
final sweepAngle = (2 * pi - (gapAngle * totalSegments)) / totalSegments;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt;
double startAngle = -pi / 2 + (gapAngle / 2);
for (int i = 0; i < totalSegments; i++) {
bool isHighlighted = progress.receivedPartIndexes.contains(i);
paint.color = isHighlighted ? activeColor : inactiveColor;
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
startAngle += sweepAngle + gapAngle;
}
}
@override
bool shouldRepaint(covariant _SegmentedCirclePainter oldDelegate) {
return !oldDelegate.progress.equals(progress) ||
oldDelegate.progress.expectedPartCount != progress.expectedPartCount;
}
}
@@ -157,17 +157,7 @@ class CoinActionRow extends StatelessWidget {
}
Future<void> _onPressedScan(BuildContext context) async {
if (false && FeatureFlag.hasNewUiExtraPages) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ScanPage(),
),
);
} else {
final code = await presentQRScanner(context);
final code = await presentQRScanner(context, showHelp: true);
if (code == null || code.isEmpty) return;
@@ -227,5 +217,4 @@ class CoinActionRow extends StatelessWidget {
),
);
}
}
}
@@ -0,0 +1,105 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/wallet_types.g.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:url_launcher/url_launcher.dart';
class ScanPageNetworkList extends StatelessWidget {
const ScanPageNetworkList({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
color: Theme.of(context).colorScheme.surface),
child: Column(
children: [
ModalTopBar(
title: S.of(context).compatible_services,
leadingIcon: Icon(Icons.arrow_back_ios_new),
onLeadingPressed: Navigator.of(context).pop,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
spacing: 32,
children: [
Column(
spacing: 24,
children: [
CakeImageWidget(
imageUrl: "assets/new-ui/scan_service.svg",
width: 75,
height: 75,
colorFilter:
ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
),
Text(
S.of(context).compatible_services_desc,
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
)
],
),
NewListSections(sections: {
"base": [
ListItemRegularRow(
keyValue: "addrs",
label: S.of(context).crypto_addresses,
showArrow: false,
iconPath: "assets/new-ui/navbar/wallets.svg",
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
subtitle: "${availableWalletTypes.length} ${S.of(context).networks}"),
ListItemRegularRow(
keyValue: "invoices",
label: S.of(context).payment_invoices,
showArrow: false,
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
iconPath: "assets/new-ui/payment_invoices.svg"),
ListItemRegularRow(
keyValue: "contacts",
label: S.of(context).contacts,
showArrow: false,
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
iconPath: "assets/new-ui/navbar/contacts.svg"),
ListItemRegularRow(
keyValue: "nodes",
label: S.of(context).nodes,
showArrow: false,
iconColor: Theme.of(context).colorScheme.onSurfaceVariant,
iconPath: "assets/new-ui/node_minimal.svg"),
],
"extra": [
ListItemRegularRow(
iconPath: "assets/new-ui/wc_min.svg",
keyValue: "wc",
label: "WalletConnect",
subtitle: S.of(context).wc_desc,
trailingIconPath: "assets/new-ui/external_link.svg",
onTap: () {
launchUrl(Uri.https("walletconnect.com"));
}),
ListItemRegularRow(
iconPath: "assets/new-ui/ocp_min.svg",
keyValue: "ocp",
label: "OpenCryptoPay",
subtitle: S.of(context).ocp_desc,
trailingIconPath: "assets/new-ui/external_link.svg",
onTap: () {
launchUrl(Uri.https("opencryptopay.io"));
})
]
})
],
),
)
],
),
);
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ class AnimatedURPage extends BasePage {
try {
switch (urQrType) {
case "ur:xmr-txunsigned": // ur:xmr-txsigned
final ur = await presentQRScanner(context);
final ur = await presentQRScanner(context, showManualInput: false);
if (ur == null) return;
final result =
await monero!.commitTransactionUR(animatedURmodel.wallet, ur);
@@ -96,7 +96,7 @@ class AnimatedURPage extends BasePage {
}
break;
case "ur:xmr-output": // xmr-keyimage
final ur = await presentQRScanner(context);
final ur = await presentQRScanner(context, showManualInput: false);
if (ur == null) return;
final result =
await monero!.importKeyImagesUR(animatedURmodel.wallet, ur);
@@ -105,7 +105,7 @@ class AnimatedURPage extends BasePage {
}
break;
case "ur:psbt": // psbt
final ur = await presentQRScanner(context);
final ur = await presentQRScanner(context, showManualInput: false);
if (ur == null) return;
await bitcoin!
.commitPsbtUR(animatedURmodel.wallet, ur.trim().split("\n"));
@@ -26,6 +26,7 @@ class ListItemRegularRowWidget extends StatelessWidget {
this.copyableText,
this.leadingIconErrorWidget,
this.leadingIconSize,
this.iconColor
});
final String keyValue;
@@ -46,6 +47,7 @@ class ListItemRegularRowWidget extends StatelessWidget {
final String? copyableText;
final Widget? leadingIconErrorWidget;
final double? leadingIconSize;
final Color? iconColor;
@override
Widget build(BuildContext context) {
@@ -85,6 +87,9 @@ class ListItemRegularRowWidget extends StatelessWidget {
width: leadingIconSize ?? 24,
height: leadingIconSize ?? 24,
errorWidget: leadingIconErrorWidget,
colorFilter: iconColor == null
? null
: ColorFilter.mode(iconColor!, BlendMode.srcIn),
)),
Flexible(
child: Column(
@@ -109,6 +109,7 @@ class NewListSections extends StatelessWidget {
bottomWidget: item.bottomWidget,
leadingIconErrorWidget: item.leadingIconErrorWidget,
leadingIconSize: item.leadingIconSize,
iconColor: item.iconColor,
);
}