ui: better QR switcher (#2477)

This commit is contained in:
cyan
2025-08-23 01:27:01 +03:00
committed by GitHub
parent 1a4ac1a07c
commit 7b600c9cd8
36 changed files with 802 additions and 152 deletions
@@ -12,7 +12,7 @@ class QrImage extends StatelessWidget {
this.embeddedImagePath,
});
final double size;
final double? size;
final Color foregroundColor;
final Color backgroundColor;
final String data;
+48 -146
View File
@@ -6,26 +6,23 @@ import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart';
import 'package:cake_wallet/src/screens/ur/widgets/urqr.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/animated_ur_model.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ur:xmr-txunsigned - unsigned transaction
// should show a scanner afterwards.
class AnimatedURPage extends BasePage {
final bool isAll;
AnimatedURPage(this.animatedURmodel, {
AnimatedURPage(
this.animatedURmodel, {
required Map<String, String> this.urQr,
this.isAll = false,
}) {
if (urQr.keys.first.startsWith("export-outputs") && animatedURmodel.wallet.type == WalletType.monero) {
if (urQr.keys.first.startsWith("export-outputs") &&
animatedURmodel.wallet.type == WalletType.monero) {
if (animatedURmodel.wallet.type == WalletType.monero) {
urQr = monero!.exportOutputsUR(animatedURmodel.wallet);
} else {
@@ -43,7 +40,6 @@ class AnimatedURPage extends BasePage {
Map<String, String> urQr = {};
final AnimatedURModel animatedURmodel;
String get urQrType {
@@ -61,20 +57,22 @@ class AnimatedURPage extends BasePage {
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 64.0),
padding: const EdgeInsets.all(32.0),
child: URQR(
urqr: urQr,
walletType: animatedURmodel.wallet.type,
),
),
if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt", BBQR.header].contains(urQrType)) ...{
SizedBox(height: 32),
if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt", BBQR.header]
.contains(urQrType)) ...{
Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: () => _continue(context),
text: "Continue",
text: "Scan QR Code",
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
@@ -87,140 +85,44 @@ class AnimatedURPage extends BasePage {
Future<void> _continue(BuildContext context) async {
try {
switch (urQrType) {
case "ur:xmr-txunsigned": // ur:xmr-txsigned
final ur = await presentQRScanner(context);
if (ur == null) return;
final result = await monero!.commitTransactionUR(animatedURmodel.wallet, ur);
if (result) {
switch (urQrType) {
case "ur:xmr-txunsigned": // ur:xmr-txsigned
final ur = await presentQRScanner(context);
if (ur == null) return;
final result =
await monero!.commitTransactionUR(animatedURmodel.wallet, ur);
if (result) {
Navigator.of(context).pop(true);
}
break;
case "ur:xmr-output": // xmr-keyimage
final ur = await presentQRScanner(context);
if (ur == null) return;
final result =
await monero!.importKeyImagesUR(animatedURmodel.wallet, ur);
if (result) {
Navigator.of(context).pop(true);
}
break;
case "ur:psbt": // psbt
final ur = await presentQRScanner(context);
if (ur == null) return;
await bitcoin!
.commitPsbtUR(animatedURmodel.wallet, ur.trim().split("\n"));
Navigator.of(context).pop(true);
}
break;
case "ur:xmr-output": // xmr-keyimage
final ur = await presentQRScanner(context);
if (ur == null) return;
final result = await monero!.importKeyImagesUR(animatedURmodel.wallet, ur);
if (result) {
Navigator.of(context).pop(true);
}
break;
case "ur:psbt": // psbt
final ur = await presentQRScanner(context);
if (ur == null) return;
await bitcoin!.commitPsbtUR(animatedURmodel.wallet, ur.trim().split("\n"));
Navigator.of(context).pop(true);
default:
throw UnimplementedError("unable to handle UR: ${urQrType}");
default:
throw UnimplementedError("unable to handle UR: ${urQrType}");
}
} catch (e) {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: e.toString(),
buttonText: S.of(context).ok,
buttonAction: () => Navigator.pop(context, true));
});
}
} catch (e) {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: e.toString(),
buttonText: S.of(context).ok,
buttonAction: () => Navigator.pop(context, true));
});
}
}
}
class URQR extends StatefulWidget {
URQR({super.key, required this.urqr});
final Map<String, String> urqr;
@override
// ignore: library_private_types_in_public_api
_URQRState createState() => _URQRState();
}
const urFrameTime = 1000 ~/ 5;
class _URQRState extends State<URQR> {
Timer? t;
int frame = 0;
@override
void initState() {
super.initState();
setState(() {
t = Timer.periodic(const Duration(milliseconds: urFrameTime), (timer) {
_nextFrame();
});
});
}
void _nextFrame() {
setState(() {
frame++;
});
}
@override
void dispose() {
t?.cancel();
super.dispose();
}
late String selected = (widget.urqr.isEmpty) ? "unknown" : widget.urqr.keys.first;
int selectedInt = 0;
List<String> get frames {
return widget.urqr[selected]?.split("\n") ?? [];
}
late String nextLabel = widget.urqr.keys.toList()[(selectedInt + 1) % widget.urqr.length] ;
void next() {
final keys = widget.urqr.keys.toList();
setState(() {
selectedInt++;
nextLabel = keys[(selectedInt + 1) % keys.length];
selected = keys[(selectedInt) % keys.length];
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: QrImage(
data: frames[frame % frames.length],
version: -1,
size: 400,
),
),
if (widget.urqr.values.length > 1)
SizedBox(
width: double.maxFinite,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: next,
text: nextLabel,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
if (FeatureFlag.hasDevOptions) ...{
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: """Current frame (${frame % frames.length}): ${frames[frame % frames.length]},
All frames:
- ${frames.join("\n - ")}"""));
},
child: Text("[dev] copy debug info"),
),
}
],
);
}
}
@@ -0,0 +1,76 @@
import 'dart:ui';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class QRFormatInfoBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
SizedBox(height: 24),
Container(
width: 80,
height: 80,
child: SvgPicture.asset('assets/images/urqr_icon.svg'),
),
SizedBox(height: 24),
Text(
S.of(context).qr_code_format,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 16),
Text(
S.of(context).qr_code_format_note,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 20),
Text(
S.of(context).qr_code_format_body,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface,
height: 1.4,
),
),
SizedBox(height: 24),
],
),
),
),
),
);
}
}
@@ -0,0 +1,163 @@
// ignore: must_be_immutable
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/widgets/base_alert_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/svg.dart';
class QRFormatSelectionDialog extends BaseAlertDialog {
final List<String> formats;
final int currentIndex;
final Function(int) onFormatSelected;
QRFormatSelectionDialog({
required this.formats,
required this.currentIndex,
required this.onFormatSelected,
});
@override
String get titleText => S.current.choose_qr_code_format;
@override
Widget content(BuildContext context) {
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.current.choose_qr_code_format_note,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
decoration: TextDecoration.none,
),
),
SizedBox(height: 16),
...formats.asMap().entries.map((entry) {
final index = entry.key;
final format = entry.value;
final isSelected = index == currentIndex;
return Container(
margin: EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onFormatSelected(index),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
if (format.startsWith('Cupcake'))
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SvgPicture.asset(
'assets/images/cupcake.svg',
width: 32,
height: 32,
fit: BoxFit.cover,
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getFormatName(format),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isSelected
? Theme.of(context)
.colorScheme
.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
Text(
_getFormatSubtitle(format),
style: TextStyle(
fontSize: 12,
color: isSelected
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withOpacity(0.6)
: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
),
],
),
),
if (isSelected) ...[
Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
],
],
),
),
),
),
);
}).toList(),
],
),
);
}
@override
String get leftActionButtonText => "";
@override
String get rightActionButtonText => "";
@override
VoidCallback get actionLeft => () {};
@override
VoidCallback get actionRight => () {};
@override
bool get isBottomDividerExists => false;
@override
Widget actionButtons(BuildContext context) {
return SizedBox.shrink(); // get rid fo cancel button
}
String _getFormatName(String format) {
return format.split(' ')[0];
}
String _getFormatSubtitle(String format) {
return format.substring(format.indexOf(' ') + 1);
}
}
+293
View File
@@ -0,0 +1,293 @@
import 'dart:async';
import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart';
import 'package:cake_wallet/src/screens/ur/widgets/qr_format_info_bottom_sheet.dart';
import 'package:cake_wallet/src/screens/ur/widgets/qr_selection_dialog.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
class URQR extends StatefulWidget {
URQR({super.key, required this.urqr, required this.walletType});
final Map<String, String> urqr;
final WalletType walletType;
@override
// ignore: library_private_types_in_public_api
_URQRState createState() => _URQRState();
}
const urFrameTime = 1000 ~/ 5;
class _URQRState extends State<URQR> {
Timer? t;
int frame = 0;
@override
void initState() {
super.initState();
setState(() {
t = Timer.periodic(const Duration(milliseconds: urFrameTime), (timer) {
_nextFrame();
});
});
}
void _nextFrame() {
setState(() {
frame++;
});
}
@override
void dispose() {
t?.cancel();
super.dispose();
}
late String selected =
(widget.urqr.isEmpty) ? "unknown" : widget.urqr.keys.first;
int selectedInt = 0;
List<String> get frames {
return widget.urqr[selected]?.split("\n") ?? [];
}
late String nextLabel =
widget.urqr.keys.toList()[(selectedInt + 1) % widget.urqr.length];
void next() {
final keys = widget.urqr.keys.toList();
setState(() {
selectedInt++;
nextLabel = keys[(selectedInt + 1) % keys.length];
selected = keys[(selectedInt) % keys.length];
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
child: QrImage(
data: frames[frame % frames.length],
version: -1,
size: null,
),
),
),
),
if (widget.urqr.values.length > 1)
widget.walletType == WalletType.monero
? _legacySwitch(context)
: _newSwitch(context),
if (FeatureFlag.hasDevOptions) ...{
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(
text:
"""Current frame (${frame % frames.length}): ${frames[frame % frames.length]},
All frames:
- ${frames.join("\n - ")}"""));
},
child: Text("[dev] copy debug info"),
),
}
],
);
}
Widget _legacySwitch(BuildContext context) {
return SizedBox(
width: double.maxFinite,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: next,
text: nextLabel,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
);
}
Widget _newSwitch(BuildContext context) {
return Column(
children: [
SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.qr_code,
color: Theme.of(context).colorScheme.primary, size: 24),
SizedBox(width: 4),
Text("QR Code Format",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
)),
],
),
SizedBox(height: 16),
Container(
margin: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showFormatSelectionDialog(context),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
if (_getCurrentFormatName().startsWith('Cupcake'))
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SvgPicture.asset(
'assets/images/cupcake.svg',
width: 40,
height: 40,
fit: BoxFit.cover,
),
),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getCurrentFormatName(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
_getCurrentFormatSubtitle(),
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.6),
),
),
],
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.4),
size: 24,
),
],
),
),
),
),
),
SizedBox(height: 16),
GestureDetector(
onTap: () => _showInfoDialog(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"What's this for?",
style: TextStyle(
fontSize: 14,
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
SizedBox(width: 4),
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
],
),
),
SizedBox(height: 32),
],
);
}
String _getCurrentFormatName() {
if (widget.urqr.isEmpty) return "Unknown";
final currentKey =
widget.urqr.keys.elementAt(selectedInt % widget.urqr.length);
return currentKey.split(' ')[0];
}
String _getCurrentFormatSubtitle() {
if (widget.urqr.isEmpty) return "";
final currentKey =
widget.urqr.keys.elementAt(selectedInt % widget.urqr.length);
return currentKey.substring(currentKey.indexOf(' ') + 1);
}
Future<void> _showFormatSelectionDialog(BuildContext context) async {
if (widget.urqr.length <= 1) return;
final keys = widget.urqr.keys.toList();
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return QRFormatSelectionDialog(
formats: keys,
currentIndex: selectedInt,
onFormatSelected: (index) {
Navigator.pop(context);
setState(() {
selectedInt = index;
selected = keys[index];
nextLabel = keys[(index + 1) % keys.length];
});
},
);
});
}
Future<void> _showInfoDialog(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return QRFormatInfoBottomSheet();
});
}
}